diff --git a/Cargo.lock b/Cargo.lock index 02e109b6b2..f9c3dacaa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6326,6 +6326,7 @@ dependencies = [ "craft-window", "eframe", "egui", + "futures-lite", "image", "log", "muda", diff --git a/ROADMAP.md b/ROADMAP.md index 87aaed1109..b5a2f43c7a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. - 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`). - 2026-11-11: Pattern libraries: Patterns › Grit Textures, a second built-in pattern library, holds eight seamless black textures (Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks) on tiles of 18 to 36 pt, each drawn the same way every time; marks that reach past a tile edge continue on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged. +- 2026-11-11: Pattern libraries: Patterns › Optical Effects, a third built-in pattern library, of VectorCraft's own designs, holds eight black wave, moiré and op-art tiles (Sine Bands, Ripple Lines, Interference Rings, Offset Rings, Warped Checks, Pinched Stripes, Chevron Pulse and Radiating Lenses) of 32 to 48 pt, each drawn the same way every time; each tile is one period of its design, and art that reaches past a tile edge continues on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged. - 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923). - 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922). - 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925). 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/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 5756600281..c5ef4d5579 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -651,6 +651,7 @@ mod portable { impl Answer { /// The answer to `request` when it couldn't be read: `why`, or no files. + #[cfg(any(test, target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] fn failed(request: Request, why: String) -> Self { match request { Request::Text => Self::Text(Err(why)), diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 72d13db08f..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))), @@ -381,6 +383,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { exists: Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())), // Background Save and Export write from a worker thread. write_shared: Some(std::sync::Arc::new(write_file)), + save_write: Some(std::sync::Arc::new(fileio::write_save_file)), + save_stream: Some(std::sync::Arc::new(fileio::write_save_file_with)), // Native saves stream into the file as they are encoded. stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { fileio::write_file_with(p, fill).map_err(|e| e.to_string()) @@ -406,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..39f86f2973 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"))] diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index 0357829aec..a9bf5d1009 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -102,13 +102,22 @@ fn answer(code: u32, results: &HashMap) -> Result) = v.try_clone().ok()?.try_into().ok()?; - Some(label) - }); + let filter = results.get("current_filter").and_then(|v| filter_label(v)); Ok(Some(Saved { path, filter })) } +/// The label of a file type as the portal sends it, a `(sa(us))` structure; `None` for a value of +/// another shape. +fn filter_label(value: &Value<'_>) -> Option { + match value { + Value::Structure(s) => match s.fields() { + [Value::Str(label), Value::Array(globs)] if globs.element_signature() == "(us)" => Some(label.to_string()), + _ => None, + }, + _ => None, + } +} + /// `file:///home/me/A%20b.svg` → `/home/me/A b.svg` (its bytes); `None` for a file elsewhere /// (`file://host/`) or another scheme. fn file_uri_path(uri: &str) -> Option> { @@ -233,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"))] @@ -396,6 +412,36 @@ mod tests { assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]); } + /// A `current_filter` of another shape than `(sa(us))` (a structure with fewer, other or more + /// fields, a string) gives the path alone; `uris` of another shape is an error. + #[test] + fn an_answer_of_another_shape_is_not_read() { + let malformed = [ + Value::from(("SVG".to_string(),)), + Value::from(("SVG".to_string(), vec![(0u32,)])), + Value::from(("SVG".to_string(), vec![("*.svg".to_string(), 0u32)])), + Value::from((1u32, vec![(0u32, "*.svg".to_string())])), + Value::from(("SVG".to_string(), vec![(0u32, "*.svg".to_string())], 1u32)), + Value::from("SVG"), + ]; + for filter in malformed { + let mut r = results("file:///tmp/card", None); + r.insert("current_filter".to_string(), OwnedValue::try_from(filter).unwrap()); + assert_eq!(answer(0, &r), Ok(Some(Saved { path: "/tmp/card".into(), filter: None }))); + } + let malformed = [ + Value::from(vec![1u32]), + Value::from(vec![("file:///tmp/card".to_string(),)]), + Value::from(Vec::::new()), + Value::from("file:///tmp/card"), + ]; + for uris in malformed { + let mut r = HashMap::new(); + r.insert("uris".to_string(), OwnedValue::try_from(uris).unwrap()); + assert!(answer(0, &r).is_err()); + } + } + /// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file /// is an error, and rfd shows the dialog instead. #[test] @@ -486,10 +532,7 @@ mod tests { } let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap)>() else { break }; let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok()); - let filter = options.get("current_filter").and_then(|v| { - let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?; - Some(label) - }); + let filter = options.get("current_filter").and_then(|v| filter_label(v)); let calls = log.lock().map(|mut l| { l.push((text("current_name"), filter)); l.len() 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/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..e449151d89 --- /dev/null +++ b/crates/color/src/gradient/spread.rs @@ -0,0 +1,268 @@ +//! 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, GradientSpread, GradientStop}; + +/// 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 than [`MAX_SPREAD_STOPS`] allows), + /// 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). + 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 one = whole_run(&self.stops); + let most = MAX_SPREAD_RUNS.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).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); + // 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 (and at least one run): past the runs + /// that fit, the last one pads. + 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 one = whole_run(&self.stops); + let n = self.repeat_count() as usize; + let kept = n.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let stops = self.runs(&one, false, 0.0, kept, n as f64); + Cow::Owned(Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..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 +} + +/// 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..2465039ca5 --- /dev/null +++ b/crates/color/src/gradient/spread/tests.rs @@ -0,0 +1,368 @@ +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, or a gradient of many stops, is written with at +/// most so many runs and stops, and pads past them; a conic, a padding gradient and one without +/// stops have nothing to pad. +#[test] +fn far_repeats_and_many_stops_are_capped() { + 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.gradient.stops.len() <= MAX_SPREAD_STOPS, "{}", p.gradient.stops.len()); + 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(_))); +} + +/// 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/blend.rs b/crates/doc/src/blend.rs index 4e51777060..2f8b40014e 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -152,6 +152,11 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option (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 }; diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index e487226d4f..f94f0ec4a8 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -133,6 +133,13 @@ pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Deco Some((d.colortype().ok()?, d)) } +/// The Orientation tag of TIFF `bytes`' first image, of any unsigned type; `None` for other images +/// and TIFFs without one. Reads only its header. +pub fn tiff_orientation(bytes: &[u8]) -> Option { + let (_, mut d) = tiff(bytes)?; + d.find_tag_unsigned::(tiff::tags::Tag::Orientation).ok().flatten() +} + /// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? fn planar(d: &mut tiff::decoder::Decoder>) -> bool { d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) @@ -309,10 +316,10 @@ fn tiff_inks(bytes: &[u8]) -> Option { Inks::new(w, h, read_samples(&mut d, n, 4)?) } -/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per -/// ink, which the image decoder doesn't read, their ink amounts written again by -/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK -/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. +/// CMYK TIFF `bytes` that no orientation turns ([`turned_cmyk`] stores the others) as Open and +/// Place store them: as they are, or, when they store one plane per ink, which the image decoder +/// doesn't read, their ink amounts written again by [`ImageBlob::cmyk_tiff_at`] with the resolution +/// `ppi`. `None` for other images and for CMYK TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> { let inks = tiff_inks(bytes)?; let (_, mut d) = tiff(bytes)?; @@ -322,6 +329,21 @@ pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes)) } +/// CMYK JPEG or TIFF `bytes` that `orientation` turns or flips, as Open and Place store them: their +/// ink amounts ([`ImageBlob::cmyk`], 8 bits each) turned and written again by +/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels, with the turned +/// width and height. `None` for other images and for CMYK images whose inks [`ImageBlob::cmyk`] +/// doesn't read. +pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option<(Vec, (u32, u32))> { + let inks = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }?; + // Four 8-bit inks to a pixel, turned as four 8-bit channels are. + let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); + px.apply_orientation(orientation); + let inks = Inks::new(px.width(), px.height(), px.into_bytes())?; + let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; + Some((Arc::unwrap_or_clone(tiff.bytes), (inks.width, inks.height))) +} + /// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at /// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink /// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB), diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 2c0a6984d0..3eac4202b6 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -450,7 +450,7 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() { envelope, NodeKind::Envelope { content, - kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }), fidelity: 100.0, editing: false, options: Default::default(), @@ -1192,7 +1192,7 @@ fn objects_at_edit_contents_uses_general_order() { d.alloc_id(), NodeKind::Envelope { content: vec![inner(a), inner(b)], - kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }), fidelity: 100.0, editing: true, options: Default::default(), diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 41a684abde..2c5759f202 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -267,7 +267,7 @@ pub struct Document { /// they were copied from instead of the current layer. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub paste_remembers_layers: bool, - /// File → File Info (the title is [`Document::title`]). + /// File → File Info (its title is [`Document::info_title`]). #[serde(default, skip_serializing_if = "skip::is_default")] pub metadata: DocMetadata, /// Effect → Document Raster Effects Settings besides the resolution diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 7233f88a50..d1eb20ed21 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -60,12 +60,12 @@ pub use envelope::{ pub(crate) use expand::max_outset; pub use expand::{ Hooks, MAX_GENERATED, Outliner, PatternTiles, affine_near, expand_deep, expand_deep_hooks, expand_live, expand_live_hooks, expand_live_with, - expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, subtree_has_live, + expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, mesh_warp_cost, mesh_warp_image, subtree_has_live, }; #[cfg(test)] use mesh::grid_eval; pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; -pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point}; +pub use warp::{WARP_NET, WarpNet, WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, uses_net, warp_point, warp_version}; #[cfg(test)] mod tests { @@ -132,6 +132,200 @@ mod tests { let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI)); assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12); } + + /// Each warp style's Bézier net, at points worked out by a separate implementation of the same + /// nets: (style, bend %, horizontal and vertical distortion %, vertical, box w × h, (u, v)) → + /// the point in the box (origin at its top left, y down). + #[test] + fn warp_nets_bend_each_style_as_its_own_patch() { + #[rustfmt::skip] + let samples = [ + ("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -31.1605665, 30.1243045), + ("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 170.241998, 58.8743443), + ("arc", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 50.6087118, 47.6275948), + ("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 18.8784581, 35.5028749), + ("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.486168, 119.348666), + ("arcLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 38.1073767, 36.0911629), + ("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 17.3830689, 17.1599587), + ("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.498463, 86.7390371), + ("arcUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 46.1081993, 36.0252855), + ("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 11.6570838), + ("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 57.3903712), + ("arch", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 49.2508226, 36.2064484), + ("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 22.6628335), + ("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 116.087703), + ("bulge", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.965576, 36.2064484), + ("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 10.6489723, 25.9914004), + ("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 208.565464, 99.4817585), + ("shellLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 41.043987, 36.1302767), + ("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -21.8346746, 41.6042708), + ("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.791499, 90.3680631), + ("shellUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.9282896, 36.1475509), + ("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 55.92), + ("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 55.44), + ("flag", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 58.098, 35.91), + ("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 40.8864), + ("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 83.7792), + ("wave", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.33, 35.91), + ("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 19.632), + ("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 62.352), + ("fish", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 44.994, 35.91), + ("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 146.64), + ("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 102.48), + ("rise", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 118.686, 35.91), + ("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 9.1136, 27.2784), + ("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.2208, 94.1472), + ("fisheye", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 47.514, 35.7048), + ("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -0.16, 27.84), + ("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.48, 97.68), + ("inflate", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.54), + ("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 40.16, 27.84), + ("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 153.52, 97.68), + ("squeeze", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.0), + ("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 32.2472, 27.9588), + ("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 149.1136, 89.2224), + ("twist", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.56325, 33.8769), + ]; + for (style, bend, dh, dv, vertical, w, h, u, v, x, y) in samples { + let Some(st) = WarpStyle::from_id(style) else { panic!("{style}") }; + let p = WarpNet::new(st, bend / 100.0, dh / 100.0, dv / 100.0, w, h, vertical).at(u, v); + assert!(p.distance(Point::new(x, y)) < 1e-6, "{style} {bend} {dh} {dv} {vertical} ({u}, {v}): {p:?} vs ({x}, {y})"); + } + } + + /// Arc's edges are concentric circles: at 100 % on a 200 × 100 box the bottom edge is the half + /// circle through the bottom corners about the bottom's middle, and the top edge the one 100 + /// further out. One cubic for a half circle strays up to 1.84 % of the radius from the circle. + #[test] + fn warp_net_arc_edges_are_concentric_circles() { + let net = WarpNet::new(WarpStyle::Arc, 1.0, 0.0, 0.0, 200.0, 100.0, false); + let center = Point::new(100.0, 100.0); + for i in 0..=20 { + let u = f64::from(i) / 20.0; + for (v, r) in [(0.0, 200.0), (1.0, 100.0)] { + let d = net.at(u, v).distance(center); + assert!((d - r).abs() < 0.0184 * r, "u {u}, v {v}: {d} from the center, not {r}"); + // Its ends and middle lie on the circle. + if i % 10 == 0 { + assert!((d - r).abs() < 1e-9, "u {u}, v {v}: {d} from the center, not {r}"); + } + } + } + // Bend 0 leaves the box as it is. + for st in [WarpStyle::Arc, WarpStyle::ShellLower, WarpStyle::Bulge, WarpStyle::Twist] { + let flat = WarpNet::new(st, 0.0, 0.0, 0.0, 200.0, 100.0, false); + assert!(flat.at(0.3, 0.7).distance(Point::new(60.0, 70.0)) < 1e-9, "{st:?}"); + } + } + + /// The horizontal distortion scales each column of the net about its middle (narrowing the left + /// side for a positive value), then the vertical one each row (narrowing the top). + #[test] + fn warp_net_distortion_scales_the_columns_then_the_rows() { + let corners = |dh: f64, dv: f64| { + let net = WarpNet::new(WarpStyle::Arc, 0.0, dh, dv, 200.0, 100.0, false); + [net.at(0.0, 0.0), net.at(1.0, 0.0), net.at(0.0, 1.0), net.at(1.0, 1.0)] + }; + let near = |got: [Point; 4], want: [(f64, f64); 4]| got.iter().zip(want).all(|(p, (x, y))| p.distance(Point::new(x, y)) < 1e-9); + assert!(near(corners(0.5, 0.0), [(0.0, 25.0), (200.0, -25.0), (0.0, 75.0), (200.0, 125.0)]), "{:?}", corners(0.5, 0.0)); + assert!(near(corners(0.0, 0.5), [(50.0, 0.0), (150.0, 0.0), (-50.0, 100.0), (250.0, 100.0)]), "{:?}", corners(0.0, 0.5)); + // Both coordinates scale: after the columns the top row runs from (0, 15) to (200, -15), and + // it then scales by 1.4 about its middle, (100, 0). + assert!(near(corners(0.3, -0.4), [(-40.0, 21.0), (240.0, -21.0), (40.0, 91.0), (160.0, 109.0)]), "{:?}", corners(0.3, -0.4)); + } + + /// Numbers no warp can take still give a finite net: a bend or distortion that isn't a number + /// counts as 0, and out of range as its limit; a size that isn't a number as 0. + #[test] + fn warp_net_takes_any_numbers() { + for st in [WarpStyle::Arc, WarpStyle::ShellUpper, WarpStyle::Wave, WarpStyle::Inflate, WarpStyle::Twist] { + for (b, dh, w, h) in + [(f64::NAN, 0.2, 10.0, 10.0), (5.0, f64::INFINITY, 10.0, 0.0), (-1e300, -3.0, f64::NAN, 1e-300), (1e-300, 0.0, 0.0, 0.0)] + { + for vertical in [false, true] { + let p = WarpNet::new(st, b, dh, dh, w, h, vertical).at(0.3, 0.6); + assert!(p.x.is_finite() && p.y.is_finite(), "{st:?} {b} {dh} {w} {h}: {p:?}"); + } + } + } + let clamped = WarpNet::new(WarpStyle::Flag, 7.0, 0.0, 0.0, 200.0, 100.0, false); + assert_eq!(clamped, WarpNet::new(WarpStyle::Flag, 1.0, 0.0, 0.0, 200.0, 100.0, false)); + } + + /// A box near the largest finite size still warps: Flag on a 1e308 × 100 box moves points only + /// up and down, as on a 1 × 1e-306 one scaled up, and on a 1e308 × 1e-300 box keeps the box's + /// height. Fisheye at 100 % on a 1.7e308 square, whose net goes past the largest finite number, + /// gives finite points inside the box and past it, and the unit square's points scaled up where + /// those are finite. Fisheye moves y by the height alone, so on boxes of the largest finite + /// width and a height of 1e-300 or 100 every y is the unit square's times the height, also + /// where x goes past the largest finite number. + #[test] + fn warp_net_bends_a_huge_box_without_overflowing() { + let w = 1e308; + let flag = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 100.0, false); + let small = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, 1.0, 100.0 / w, false); + for (u, v) in [(0.0, 0.0), (0.3, 0.6), (0.9, 1.0), (1.0, 0.5)] { + let (p, q) = (flag.at(u, v), small.at(u, v)); + assert!(p.is_finite(), "({u}, {v}): {p:?}"); + assert!((p.x - u * w).abs() <= 1e-12 * w, "({u}, {v}): {p:?}"); + assert!((p.y - q.y * w).abs() <= 1e-9 * p.y.abs().max(1.0), "({u}, {v}): {p:?} against {q:?}"); + } + let thin = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 1e-300, false); + assert_eq!(thin.at(0.0, 1.0), Point::new(0.0, 1e-300)); + let fisheye = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.7e308, 1.7e308, false); + for (u, v) in [(0.1, 0.1), (0.8, 0.3), (1.5, 0.5), (3.0, 3.0)] { + let p = fisheye.at(u, v); + assert!(p.is_finite(), "({u}, {v}): {p:?}"); + } + let unit = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.0, 1.0, false); + let near = |a: f64, b: f64| (a - b).abs() <= 1e-12 * b.abs().max(f64::MIN_POSITIVE); + let (p, q) = (fisheye.at(0.1, 0.1), unit.at(0.1, 0.1)); + assert!(near(p.x, q.x * 1.7e308) && near(p.y, q.y * 1.7e308), "{p:?} against {q:?}"); + // The bottom of a box with a subnormal height stays exactly at that height: the net is + // built on the box halved, and the height's lowest bit but one is set. + let low = f64::MIN_POSITIVE / 4.0 + f64::from_bits(2); + let sub = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, low, false).at(0.0, 1.0); + assert_eq!(sub.y, low, "{sub:?}"); + for h in [1e-300, 100.0] { + let tall = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, h, false); + for (u, v) in [(0.1, 0.1), (0.9, 0.4), (0.5, 0.8)] { + let (p, q) = (tall.at(u, v), unit.at(u, v)); + assert!(p.is_finite() && near(p.y, q.y * h), "h {h}, ({u}, {v}): {p:?} against {q:?}"); + } + } + } + + /// Content with no width or no height takes the middle of a warp envelope's patch along that + /// side: under Arc a vertical line stays vertical, under Bulge a horizontal line stays straight. + #[test] + fn warp_envelopes_bend_flat_content_as_the_middle_of_the_patch() { + let warp = |style: &str| EnvelopeKind::Warp { style: style.into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; + let arc = warp("arc"); + let map = envelope_mapper(&arc, Rect::new(100.0, 0.0, 100.0, 100.0)); + for y in [0.0, 30.0, 100.0] { + let p = map(Point::new(100.0, y)); + assert!((p.x - 100.0).abs() <= 1e-9, "{y}: {p:?}"); + } + let bulge = warp("bulge"); + let map = envelope_mapper(&bulge, Rect::new(0.0, 50.0, 200.0, 50.0)); + for x in [0.0, 70.0, 200.0] { + let p = map(Point::new(x, 50.0)); + assert!((p.y - 50.0).abs() <= 1e-9, "{x}: {p:?}"); + } + } + + /// A warp envelope's map version: none in a file reads as 0 (the earlier map), 0 is written + /// as none, so files keep their bytes, and the nets' version is written. + #[test] + fn warp_envelopes_write_their_map_version_only_when_it_has_one() { + let old: EnvelopeKind = serde_json::from_str(r#"{"type":"warp","style":"arc","bend":20}"#).unwrap(); + assert!(matches!(old, EnvelopeKind::Warp { version: 0, .. }), "{old:?}"); + assert!(!serde_json::to_string(&old).unwrap().contains("version")); + let new = EnvelopeKind::Warp { style: "arc".into(), bend: 20.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; + let text = serde_json::to_string(&new).unwrap(); + assert!(text.contains(r#""version":1"#), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), new); + } use vectorcraft_geom::shapes; fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc { @@ -537,7 +731,7 @@ mod tests { #[test] fn an_envelope_maps_from_its_box_with_its_margins() { let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art()); - let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let content = [Arc::new(rect)]; let src = |content: &[Arc], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src(); // Top, bottom, left and right. diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 8abe961841..f197837020 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -3,6 +3,10 @@ use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of}; use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes}; use vectorcraft_geom::Homography; +fn is_zero(n: &u32) -> bool { + *n == 0 +} + /// How an envelope distorts its content. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] @@ -19,6 +23,10 @@ pub enum EnvelopeKind { v: f64, #[serde(default = "yes")] horizontal: bool, + /// The map the warp draws with: [`WARP_NET`](super::WARP_NET) for its style's Bézier net, + /// 0 (as in files from before the version existed) for [`warp_point`](super::warp_point). + #[serde(default, skip_serializing_if = "is_zero")] + version: u32, }, /// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto. Mesh { diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 8675dcc011..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 { @@ -178,29 +179,46 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { a.is_finite().then_some(a) } -/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into -/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map -/// that takes the triangle where the warp takes its corners (with Preserve Shape Using -/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing). -/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no -/// seams show. +/// Image `n` (`im`) pushed through the envelope's warp as a raster mesh warp ([`mesh_warp_image`] +/// with `w.cells` cells, Preserve Shape Using Transparency and Anti-Alias as the envelope's +/// options say). fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { let k = w.cells.max(1); - // Two triangles a cell, three objects each; an image an inner envelope already cut into - // pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image - // is left out. - if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) { + // An image an inner envelope already cut into pieces is cut again here, so the pieces count + // against [`MAX_GENERATED`]. Past it, the image is left out. + if !Generating::charge(mesh_warp_cost(k)) { let mut g = n.clone(); g.kind = NodeKind::Group { children: vec![], clip: false }; return g; } + mesh_warp_image(n, im, w.f, k, !w.options.anti_alias, w.options.preserve_shape == PreserveShape::Transparency) +} + +/// How many objects [`mesh_warp_image`] makes of one image with `cells` rows and columns: two +/// triangles a cell, three objects each. +pub fn mesh_warp_cost(cells: usize) -> usize { + let k = cells.max(1); + k.saturating_mul(k).saturating_mul(6) +} + +/// Image `n` (`im`) pushed through point map `f` as a raster mesh warp: its frame cut into +/// `cells`² cells of two triangles, each a clip group showing the image under the affine map +/// that takes the triangle where `f` takes its corners (with `masked`, the image masked by the +/// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past +/// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the +/// color `n` colorizes a grayscale image with. The result is a group with `n`'s id, name, +/// transparency, opacity mask and appearance. +pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node { + let k = cells.max(1); + // A grayscale image's fill color colorizes each piece; the group of pieces takes none. + let colorize = n.extras().colorize.clone(); let (pw, ph) = (im.width as f64, im.height as f64); let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64); let mut pieces = Vec::with_capacity(2 * k * k); for j in 0..k { for i in 0..k { let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]; - let d = s.map(|p| (w.f)(im.xf * p)); + let d = s.map(|p| f(im.xf * p)); for t in [[0, 1, 2], [0, 2, 3]] { let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v])); let Some(xf) = affine_between(st, dt) else { continue }; @@ -216,8 +234,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { .collect(); let shape = PathData::single(SubPath::polyline(&tri, true)); let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); - image.extras_mut().pixelated = !w.options.anti_alias; - if w.options.preserve_shape == PreserveShape::Transparency { + image.extras_mut().pixelated = pixelated; + image.extras_mut().colorize = colorize.clone(); + if masked { // Preserve Shape Using Transparency: the triangle is the piece's alpha (a // white opacity mask) instead of its clipping path. let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); @@ -234,6 +253,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { } let mut g = n.clone(); g.kind = NodeKind::Group { children: pieces, clip: false }; + if let Some(e) = g.extras_if_any() { + e.colorize = None; + } g } diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index c59374dee9..be865e2769 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -1,5 +1,5 @@ use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at}; -use super::warp::{WarpStyle, flatten_subpath, resample, warp_point}; +use super::warp::{WarpNet, WarpStyle, flatten_subpath, resample, uses_net, warp_point}; use super::{ Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color, one, @@ -1731,10 +1731,19 @@ pub(super) fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box Point + 'a> { let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height()); match kind { - EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => { + EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal, version } => { let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc); let b = bend.clamp(-100.0, 100.0) / 100.0; let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0); + if uses_net(*version) { + let net = WarpNet::new(st, b, dh, dv, src.width(), src.height(), !*horizontal); + // Content with no width or no height takes the middle of the patch along that side. + let (flat_u, flat_v) = (src.width() <= 0.0, src.height() <= 0.0); + return Box::new(move |u: f64, v: f64| { + let (u, v) = (if flat_u { 0.5 } else { u }, if flat_v { 0.5 } else { v }); + frame * (src.origin() + net.at(u, v).to_vec2()) + }); + } let c = src.center(); let (hw, hh) = (src.width() / 2.0, src.height() / 2.0); let horizontal = *horizontal; diff --git a/crates/doc/src/live/warp.rs b/crates/doc/src/live/warp.rs index 6d7392ac5f..08bef91140 100644 --- a/crates/doc/src/live/warp.rs +++ b/crates/doc/src/live/warp.rs @@ -109,6 +109,337 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f6 (x2, y2) } +// ===================================================================================== +// Warp nets: each style as one Bézier patch over its box +// ===================================================================================== + +/// The `version` a Warp effect's params or a warp envelope carries for the [`WarpNet`] map. With +/// none (or 0) a warp draws with [`warp_point`], as every warp did before, so saved documents keep +/// their look; new and imported warps are given this one. +pub const WARP_NET: u32 = 1; + +/// Does a warp of map `version` draw with [`WarpNet`]? A version newer than this build knows draws +/// with the newest map it has. +pub fn uses_net(version: u32) -> bool { + version >= WARP_NET +} + +/// The map version `v` names when it is one this build draws: a whole number from 0 to +/// [`WARP_NET`], written as an integer or as a float (`1` or `1.0`). +pub fn warp_version(v: &serde_json::Value) -> Option { + let n = v.as_f64()?; + (n.fract() == 0.0 && (0.0..=f64::from(WARP_NET)).contains(&n)).then_some(n as u32) +} + +/// A style's own control net: `rows` × `cols` points (2 to 4 each) of a Bézier patch, row by row +/// from the top, in the box's coordinates (origin at its top left, y down). +#[derive(Clone, Copy, Debug)] +struct Net { + rows: usize, + cols: usize, + p: [[Point; 4]; 4], +} + +impl Net { + /// The undistorted box `w` × `h` as a `rows` × `cols` net. + fn flat(w: f64, h: f64, rows: usize, cols: usize) -> Self { + let mut p = [[Point::ZERO; 4]; 4]; + for (r, row) in p.iter_mut().enumerate().take(rows) { + for (c, q) in row.iter_mut().enumerate().take(cols) { + *q = Point::new(w * (c as f64 / (cols - 1) as f64), h * (r as f64 / (rows - 1) as f64)); + } + } + Self { rows, cols, p } + } + + /// Upside down within a box `h` high: the rows in reverse order, each point mirrored. + fn mirrored(self, h: f64) -> Self { + let mut out = self; + for (to, from) in out.p[..self.rows].iter_mut().zip(self.p[..self.rows].iter().rev()) { + for (q, f) in to.iter_mut().zip(from).take(self.cols) { + *q = Point::new(f.x, h - f.y); + } + } + out + } + + /// Rows and columns exchanged, and x with y: a net made on the box turned on its side. + fn transposed(self) -> Self { + let mut out = Self { rows: self.cols, cols: self.rows, p: [[Point::ZERO; 4]; 4] }; + for (r, row) in self.p[..self.rows].iter().enumerate() { + for (c, q) in row[..self.cols].iter().enumerate() { + out.p[c][r] = Point::new(q.y, q.x); + } + } + out + } + + /// Each column scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from + /// 0 at the left column to 1 at the right: a horizontal distortion `a` (-1..1). + fn distort_columns(&mut self, a: f64) { + let (rows, cols) = (self.rows, self.cols); + for c in 0..cols { + let s = 1.0 + a * (2.0 * c as f64 / (cols - 1) as f64 - 1.0); + let mid = half_way(self.p[0][c], self.p[rows - 1][c]); + for row in &mut self.p[..rows] { + row[c] = mid + (row[c] - mid) * s; + } + } + } + + /// Each row scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from 0 + /// at the top row to 1 at the bottom: a vertical distortion `a` (-1..1). + fn distort_rows(&mut self, a: f64) { + let (rows, cols) = (self.rows, self.cols); + for (r, row) in self.p[..rows].iter_mut().enumerate() { + let s = 1.0 + a * (2.0 * r as f64 / (rows - 1) as f64 - 1.0); + let mid = half_way(row[0], row[cols - 1]); + for q in &mut row[..cols] { + *q = mid + (*q - mid) * s; + } + } + } + + /// The same patch as a bicubic net (degree elevation, which leaves the surface as it is). + fn bicubic(self) -> [[Point; 4]; 4] { + let rows: Vec<[Point; 4]> = self.p[..self.rows].iter().map(|row| cubic_line(&row[..self.cols])).collect(); + let mut out = [[Point::ZERO; 4]; 4]; + for c in 0..4 { + let col: Vec = rows.iter().map(|row| row[c]).collect(); + for (to, q) in out.iter_mut().zip(cubic_line(&col)) { + to[c] = q; + } + } + out + } +} + +/// The point half way from `a` to `b`, as halves added: two finite points always give a finite one. +fn half_way(a: Point, b: Point) -> Point { + Point::new(0.5 * a.x + 0.5 * b.x, 0.5 * a.y + 0.5 * b.y) +} + +/// A Bézier curve of 2 to 4 control points as the 4 of the same curve's cubic form. +fn cubic_line(p: &[Point]) -> [Point; 4] { + match *p { + [a, b] => [a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b], + [a, b, c] => [a, a.lerp(b, 2.0 / 3.0), c.lerp(b, 2.0 / 3.0), c], + [a, b, c, d] => [a, b, c, d], + _ => [Point::ZERO; 4], + } +} + +/// The handle offsets (along the chord, across it) of a cubic circular arc over a chord `chord` +/// long that turns through θ = |`b`| × 180°: each handle is 4/3 tan(θ/4) of the radius long, along +/// the arc's end tangent. With the radius `chord / (2 sin(θ/2))` that length is +/// `chord / (3 cos²(θ/4))`, which a bend near 0 leaves at a third of the chord. +fn arc_handles(chord: f64, b: f64) -> (f64, f64) { + let th = b.abs().min(1.0) * PI; + let len = chord / (3.0 * (th / 4.0).cos().powi(2)); + (len * (th / 2.0).cos(), len * (th / 2.0).sin()) +} + +/// A row from (0, `y`) to (`w`, `y`) bent into a circular arc of |`b`| × 180°: up for a positive +/// `b` when `up`, down when not, the other way for a negative one. +fn arc_row(w: f64, y: f64, b: f64, up: bool) -> [Point; 4] { + let (ax, ay) = arc_handles(w, b); + let dy = if (b > 0.0) == up { -ay } else { ay }; + [Point::new(0.0, y), Point::new(ax, y + dy), Point::new(w - ax, y + dy), Point::new(w, y)] +} + +/// Arc at bend |`b`| as four rows: the bottom edge bends up through the bottom corners, the top +/// edge is the arc around the same center `h` further out, and the rows between are spaced evenly +/// along the radii. The top corners lie `h` out from the bottom ones along the radii, which lean +/// out by θ/2. +fn arc_rows(w: f64, h: f64, b: f64) -> [[Point; 4]; 4] { + let th = b.abs().min(1.0) * PI; + let (s, c) = (th / 2.0).sin_cos(); + let (tl, tr) = (Point::new(-h * s, h * (1.0 - c)), Point::new(w + h * s, h * (1.0 - c))); + let (ax, ay) = arc_handles(tr.x - tl.x, b); + let top = [tl, Point::new(tl.x + ax, tl.y - ay), Point::new(tr.x - ax, tr.y - ay), tr]; + let bottom = arc_row(w, h, b.abs(), true); + let mut out = [[Point::ZERO; 4]; 4]; + for (r, row) in out.iter_mut().enumerate() { + for (q, (t, u)) in row.iter_mut().zip(top.iter().zip(bottom)) { + *q = t.lerp(u, r as f64 / 3.0); + } + } + out +} + +/// Warp style `style`'s own net at bend `b` (-1..1) over the box `w` × `h`, before distortion. +/// `vertical`: the net of the box turned on its side, where Rise moves the other half. +fn style_net(style: WarpStyle, b: f64, w: f64, h: f64, vertical: bool) -> Net { + use WarpStyle::*; + let two = |top: [Point; 4], bottom: [Point; 4]| { + let mut p = [[Point::ZERO; 4]; 4]; + (p[0], p[1]) = (top, bottom); + Net { rows: 2, cols: 4, p } + }; + let flat_row = |y: f64| [0.0, 1.0, 2.0, 3.0].map(|c| Point::new(w * c / 3.0, y)); + match style { + Arc => { + let rows = arc_rows(w, h, b); + let net = two(rows[0], rows[3]); + if b < 0.0 { net.mirrored(h) } else { net } + } + ArcUpper => two(arc_row(w, 0.0, b, true), flat_row(h)), + ArcLower => two(flat_row(0.0), arc_row(w, h, b, false)), + Arch => { + let top = arc_row(w, 0.0, b, true); + two(top, top.map(|q| Point::new(q.x, q.y + h))) + } + Bulge => two(arc_row(w, 0.0, b, true), arc_row(w, h, b, false)), + ShellLower | ShellUpper => { + let a = arc_rows(w, h, b); + let mut net = Net::flat(w, h, 4, 4); + if b >= 0.0 { + // The outer arc and the ends of the row inside it, upside down for Shell Lower. + net.p[0] = a[0]; + (net.p[1][0], net.p[1][3]) = (a[1][0], a[1][3]); + if style == ShellLower { net.mirrored(h) } else { net } + } else { + // The inner arc and the ends of the row outside it, upside down for Shell Upper. + net.p[3] = a[3]; + (net.p[2][0], net.p[2][3]) = (a[2][0], a[2][3]); + if style == ShellUpper { net.mirrored(h) } else { net } + } + } + Flag | Fish | Rise => { + let mut net = Net::flat(w, h, 2, 4); + let d = 2.0 * b * h; + match style { + Flag => { + for row in &mut net.p[..2] { + (row[1].y, row[2].y) = (row[1].y + d, row[2].y - d); + } + } + Fish => { + (net.p[0][1].y, net.p[0][2].y) = (net.p[0][1].y - d, net.p[0][2].y + d); + (net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d); + } + _ => { + for row in &mut net.p[..2] { + if vertical { + (row[2].y, row[3].y) = (row[2].y - d, row[3].y - d); + } else { + (row[0].y, row[1].y) = (row[0].y + d, row[1].y + d); + } + } + } + } + net + } + Wave => { + let mut net = Net::flat(w, h, 3, 4); + let d = 2.0 * b * h; + (net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d); + net + } + Fisheye => { + let mut net = Net::flat(w, h, 4, 4); + let c = Point::new(w / 2.0, h / 2.0); + for row in &mut net.p[1..3] { + for q in &mut row[1..3] { + *q = c + (*q - c) * (1.0 + 4.0 * b); + } + } + net + } + Inflate | Squeeze => { + let mut net = Net::flat(w, h, 3, 3); + let side = if style == Inflate { b * w / 2.0 } else { -b * w / 2.0 }; + net.p[0][1].y -= b * h / 2.0; + net.p[2][1].y += b * h / 2.0; + net.p[1][0].x -= side; + net.p[1][2].x += side; + net + } + Twist => { + // The four inner points move round: clockwise for a positive bend. + let mut net = Net::flat(w, h, 4, 4); + let (dx, dy) = (b.abs() * w, b.abs() * h); + if b >= 0.0 { + net.p[1][1].x += dx; + net.p[1][2].y += dy; + net.p[2][2].x -= dx; + net.p[2][1].y -= dy; + } else { + net.p[1][1].y += dy; + net.p[1][2].x -= dx; + net.p[2][2].y -= dy; + net.p[2][1].x += dx; + } + net + } + } +} + +/// A warp as one bicubic Bézier patch over its box: the style's own net at the bend, its columns +/// scaled by the horizontal distortion and then its rows by the vertical one. Points are in the +/// box's coordinates (origin at its top left, y down). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WarpNet { + /// The control points over the box divided by `scale`. + net: [[Point; 4]; 4], + /// 1, or a power of two when the net over the box itself does not fit in finite numbers. + scale: f64, + w: f64, + h: f64, +} + +impl WarpNet { + /// The net of `style` over a box `w` × `h` at bend `b` and distortions `dh` and `dv` (each + /// -1..1, clamped); `vertical` bends along the box's height. + pub fn new(style: WarpStyle, b: f64, dh: f64, dv: f64, w: f64, h: f64, vertical: bool) -> Self { + let unit = |x: f64| if x.is_finite() { x.clamp(-1.0, 1.0) } else { 0.0 }; + let size = |x: f64| if x.is_finite() { x.max(0.0) } else { 0.0 }; + let (b, dh, dv, w, h) = (unit(b), unit(dh), unit(dv), size(w), size(h)); + let build = |w: f64, h: f64| { + let mut net = if vertical { style_net(style, b, h, w, true).transposed() } else { style_net(style, b, w, h, false) }; + net.distort_columns(dh); + net.distort_rows(dv); + net.bicubic() + }; + // A box near the largest finite size can overflow the net. The net is then built on the box + // divided by the smallest power of two that fits (2 to 4096), an exact division for every + // side above the subnormal range, and points are multiplied back as they are read. + let mut out = Self { net: build(w, h), scale: 1.0, w, h }; + while out.scale < 4096.0 && !out.net.iter().flatten().all(|q| q.is_finite()) { + let scale = out.scale * 2.0; + out = Self { net: build(w / scale, h / scale), scale, w, h }; + } + out + } + + /// The point at `(u, v)` of the box (0..1 across and down; outside, the patch goes on). A + /// coordinate the patch would send past the largest finite number is the box's own, held to + /// the finite range. + pub fn at(&self, u: f64, v: f64) -> Point { + let p = self.at_scaled(u, v); + let held = |x: f64| if x.is_nan() { 0.0 } else { x.clamp(-f64::MAX, f64::MAX) }; + let coord = |x: f64, flat: f64| if (x * self.scale).is_finite() { x * self.scale } else { held(flat) }; + Point::new(coord(p.x, u * self.w), coord(p.y, v * self.h)) + } + + /// [`Self::at`] divided by the net's scale. + fn at_scaled(&self, u: f64, v: f64) -> Point { + let bern = |t: f64| { + let s = 1.0 - t; + [s * s * s, 3.0 * t * s * s, 3.0 * t * t * s, t * t * t] + }; + let (bu, bv) = (bern(u), bern(v)); + let (mut x, mut y) = (0.0, 0.0); + for (row, wv) in self.net.iter().zip(bv) { + for (q, wu) in row.iter().zip(bu) { + x += wv * wu * q.x; + y += wv * wu * q.y; + } + } + Point::new(x, y) + } +} + /// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them. fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez { let c = sp.segment(i); diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs index 151365c5d2..908b649e4d 100644 --- a/crates/doc/src/metadata.rs +++ b/crates/doc/src/metadata.rs @@ -1,11 +1,15 @@ //! File → File Info: the document's descriptive metadata (author, description, keywords, rating, //! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The -//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved -//! before File Info existed load unchanged. +//! document title is [`Document::title`], the name it shows in its tab and offers Save As when it +//! has no file; a title the file it was opened from gave itself is [`DocMetadata::title`], and +//! [`Document::info_title`] is the title File Info shows and exports write. Every field is +//! defaulted, so documents saved before File Info existed load unchanged. use chrono::{Datelike, Timelike}; use serde::{Deserialize, Serialize}; +use crate::Document; + /// File Info → Copyright Status. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -44,6 +48,13 @@ pub const MAX_RATING: u8 = 5; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct DocMetadata { + /// The title the file this document was opened from gives itself (an SVG's root ``, a + /// PDF's XMP `dc:title` or Info `Title`), which File Info shows and exports write in place of + /// [`Document::title`], the document's display name. `None`: the file gave none, so the + /// display name is the title. Left out of a saved document when unset, so other documents + /// save as they did. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option<String>, pub author: String, /// The author's position or job title. pub author_title: String, @@ -83,6 +94,16 @@ impl DocMetadata { } } +impl Document { + /// The title File Info shows and the files an export writes carry (PDF `Title` and XMP + /// `dc:title`, SVG `<title>`, PNG `Title`): the title the file this document was opened from + /// gave itself ([`DocMetadata::title`]), else [`Document::title`]. The display name (the tab, the + /// default file name) is always [`Document::title`]. + pub fn info_title(&self) -> &str { + self.metadata.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&self.title) + } +} + /// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time` /// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock, /// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same @@ -191,6 +212,24 @@ mod tests { assert!(DocMetadata::default().png_text("").is_empty()); } + #[test] + fn the_info_title_is_the_files_own_title_else_the_display_name() { + let mut d = Document::new(10.0, 10.0); + d.title = "box.svg".into(); + assert_eq!(d.info_title(), "box.svg"); + d.metadata.title = Some("Access paths".into()); + assert_eq!((d.title.as_str(), d.info_title()), ("box.svg", "Access paths")); + d.metadata.title = Some(" ".into()); + assert_eq!(d.info_title(), "box.svg"); + // Saved only when set: other documents' metadata is as before, and the field comes back. + d.metadata.title = None; + assert!(!serde_json::to_string(&d.metadata).unwrap().contains("title")); + d.metadata.title = Some("Access paths".into()); + let json = serde_json::to_string(&d.metadata).unwrap(); + assert_eq!(serde_json::from_str::<DocMetadata>(&json).unwrap().title.as_deref(), Some("Access paths")); + assert_eq!(serde_json::from_str::<DocMetadata>("{}").unwrap().title, None); + } + /// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative /// decimal integer; anything else leaves the clock to decide, so an export is never refused. #[test] diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index cace68ce66..26a11cf733 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -180,6 +180,24 @@ impl ObjectsOnPath { }); Some((spacing, start)) } + /// Move the object `member` to the place on the path of the object `target`: the objects + /// between them each move one place toward where `member` was, except the objects for which + /// `fixed` is true, which keep their places and are passed over. Only the order changes, not the + /// Space or the start. Returns `member`'s new index, or `None` when either one is not an object + /// of the group. + pub fn move_member(&mut self, member: NodeId, target: NodeId, fixed: impl Fn(NodeId) -> bool) -> Option<usize> { + let moves = |id: NodeId| id == member || id == target || !fixed(id); + let slots: Vec<usize> = (0..self.members.len()).filter(|&i| moves(self.members[i].id)).collect(); + let from = slots.iter().position(|&i| self.members[i].id == member)?; + let to = slots.iter().position(|&i| self.members[i].id == target)?; + let mut moving: Vec<_> = slots.iter().map(|&i| self.members[i]).collect(); + let m = moving.remove(from); + moving.insert(to, m); + for (&i, m) in slots.iter().zip(moving) { + self.members[i] = m; + } + Some(slots[to]) + } /// The children given new ids (a copy of the group): the path and the objects by their new ids. pub fn rename(&mut self, ids: &HashMap<NodeId, NodeId>) { let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id); @@ -487,6 +505,36 @@ mod tests { assert_eq!(layout(400.0, true, 4, Some(300.0), 400.0), (0.0, 100.0)); } + #[test] + fn moving_an_object_shifts_the_ones_between_it_and_its_new_place() { + let (a, b, c) = + (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)), rect(4, Rect::new(40.0, 0.0, 50.0, 10.0))); + let rec = ObjectsOnPath { spacing: Some(30.0), start: 5.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b, &c], CENTER, 15.0) }; + let order = |r: &ObjectsOnPath| r.members.iter().map(|m| m.id.0).collect::<Vec<_>>(); + let free = |_: NodeId| false; + let mut fwd = rec.clone(); + assert_eq!(fwd.move_member(NodeId(2), NodeId(4), free), Some(2)); + assert_eq!(order(&fwd), [3, 4, 2], "the first to the last place"); + assert_eq!((fwd.spacing, fwd.start, fwd.pivot, fwd.rotate), (rec.spacing, rec.start, rec.pivot, rec.rotate)); + let mut back = rec.clone(); + assert_eq!(back.move_member(NodeId(4), NodeId(2), free), Some(0)); + assert_eq!(order(&back), [4, 2, 3], "the last to the first place"); + let mut same = rec.clone(); + assert_eq!(same.move_member(NodeId(3), NodeId(3), free), Some(1)); + assert_eq!(same, rec, "onto its own place"); + let mut other = rec.clone(); + assert_eq!(other.move_member(NodeId(1), NodeId(3), free), None, "the path is not one of the objects"); + assert_eq!(other.move_member(NodeId(3), NodeId(9), free), None); + assert_eq!(other, rec); + // A fixed object between them keeps its place; the others pass over it. + let mut over = rec.clone(); + assert_eq!(over.move_member(NodeId(2), NodeId(4), |id| id == NodeId(3)), Some(2)); + assert_eq!(order(&over), [4, 3, 2], "the first and last trade places round the fixed one"); + let mut back_over = rec.clone(); + assert_eq!(back_over.move_member(NodeId(4), NodeId(2), |id| id == NodeId(3)), Some(0)); + assert_eq!(order(&back_over), [4, 3, 2]); + } + #[test] fn a_group_saved_without_space_reads_as_auto() { let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index c514aab996..6f0db556e0 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -59,6 +59,11 @@ pub struct Selection { /// that object ([`crate::symbol_overrides`]). Any other selection change clears it. #[serde(default, skip_serializing_if = "Option::is_none")] pub symbol_part: Option<SymbolPart>, + /// An object of an Objects on Path group, chosen with its Select/Move widget: the group is the + /// one selected object, and the object is one of its objects on the path + /// ([`crate::objects_on_path::ObjectsOnPath::members`]). Any other selection change clears it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_path_member: Option<NodeId>, } /// See [`Selection::symbol_part`]. @@ -94,6 +99,13 @@ impl Selection { self.guides.clear(); self.key_guide = None; self.symbol_part = None; + self.on_path_member = None; + } + /// Select only the Objects on Path group `group`, with its object `member` (see + /// [`Selection::on_path_member`]). + pub fn set_on_path_member(&mut self, group: NodeId, member: NodeId) { + self.set([group]); + self.on_path_member = Some(member); } /// Select only instance `instance`, with the object at `path` of its symbol's art (see /// [`Selection::symbol_part`]). @@ -115,6 +127,7 @@ impl Selection { self.target = None; self.slices.clear(); self.symbol_part = None; + self.on_path_member = None; if !self.objects.contains(&id) { self.objects.push(id); } @@ -123,6 +136,7 @@ impl Selection { self.target = None; self.slices.clear(); self.symbol_part = None; + self.on_path_member = None; self.objects.retain(|x| *x != id); self.anchors.remove(&id); self.segments.remove(&id); @@ -152,6 +166,7 @@ impl Selection { /// Shift-click on a guide). pub fn toggle_guide(&mut self, i: NodeId) { self.symbol_part = None; + self.on_path_member = None; if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); if self.key_guide == Some(i) { @@ -258,6 +273,22 @@ impl Selection { if !part_kept { self.symbol_part = None; } + // A chosen object on a path needs its Objects on Path group selected alone, with no anchors + // or guides, and still one of the group's objects, neither hidden nor locked. + let member_kept = self.on_path_member.is_some_and(|m| { + let [g] = self.objects.as_slice() else { return false }; + self.anchors.is_empty() + && self.guides.is_empty() + && doc.is_editable(m) + && found + .get(g) + .and_then(|p| doc.node_at(p)) + .and_then(|n| n.extras().objects_on_path.as_deref().filter(|_| crate::objects_on_path::is_objects_on_path(n))) + .is_some_and(|rec| rec.members.iter().any(|x| x.id == m)) + }); + if !member_kept { + self.on_path_member = None; + } } /// Deselect the objects that are hidden or locked, themselves or through a group or layer /// around them (and so the key object when it is one): hidden or locked art can't stay diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index d0d81faee6..d9df845129 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -179,6 +179,49 @@ fn selection_changes_clear_the_target() { assert_eq!(s.target, None); } +#[test] +fn a_chosen_object_on_a_path_needs_its_group_selected_alone() { + use vectorcraft_geom::{Point, Rect, shapes}; + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (line, a, b, g, other) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + let line = Node::path(line, shapes::line(Point::new(0.0, 100.0), Point::new(200.0, 100.0)), Default::default()); + let a = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Default::default()); + let b = Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Default::default()); + let rec = crate::objects_on_path::ObjectsOnPath::new(line.id, &[&a, &b], crate::objects_on_path::CENTER, 0.0); + let (line_id, a_id) = (line.id, a.id); + let mut group = Node::new(g, NodeKind::Group { children: vec![Arc::new(line), Arc::new(a), Arc::new(b)], clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + d.insert(Some(l), 0, group).unwrap(); + d.insert(Some(l), 1, Node::path(other, shapes::rectangle(Rect::new(150.0, 0.0, 160.0, 10.0)), Default::default())).unwrap(); + let mut s = Selection::default(); + s.set_on_path_member(g, a_id); + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], Some(a_id))); + s.add(other); + assert_eq!(s.on_path_member, None, "another object joins the selection"); + s.set_on_path_member(g, a_id); + s.set([g]); + assert_eq!(s.on_path_member, None, "a new selection"); + // The path is not one of the objects, nor is an object outside the group. + for m in [line_id, other] { + s.set_on_path_member(g, m); + s.prune(&d); + assert_eq!(s.on_path_member, None, "{m:?}"); + } + // A locked object is not chosen. + s.set_on_path_member(g, a_id); + d.node_mut(a_id).unwrap().locked = true; + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None), "locked"); + d.node_mut(a_id).unwrap().locked = false; + // Once the group is a plain group, no object of it is chosen. + s.set_on_path_member(g, a_id); + d.node_mut(g).unwrap().extras_mut().objects_on_path = None; + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None)); +} + #[test] fn a_key_object_needs_two_or_more_selected_objects() { let mut s = Selection::default(); diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 4ecb8948bb..1952060663 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -194,8 +194,14 @@ fn round(h: &str, width: f64) -> StrokeLayer { StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..stroke(h, width) } } -/// Live effect `id` with `params` (the others keep the effect's defaults). -fn fx(id: &str, params: Value) -> Effect { +/// Live effect `id` with `params` (the others keep the effect's defaults). A Warp draws with its +/// style's Bézier net, as a new Warp does. +fn fx(id: &str, mut params: Value) -> Effect { + if id.starts_with("warp.") + && let Value::Object(m) = &mut params + { + m.entry("version").or_insert(json!(crate::live::WARP_NET)); + } Effect { id: id.into(), params, visible: true } } @@ -657,6 +663,24 @@ mod tests { assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn"); } + /// The built-in styles' Warps draw with the map a new Warp takes. + #[test] + fn builtin_warp_styles_take_the_nets_map() { + let mut warps = 0; + for b in STYLE_LIBRARIES { + let lib = builtin_style_library(b.id).unwrap(); + for g in &lib.styles { + let mut effects: Vec<&Effect> = g.appearance.effects.iter().collect(); + effects.extend(g.appearance.items.iter().flat_map(|i| i.effects())); + for e in effects.into_iter().filter(|e| e.id.starts_with("warp.")) { + assert_eq!(e.params["version"], json!(crate::live::WARP_NET), "{} › {}: {}", b.id, g.name, e.id); + warps += 1; + } + } + } + assert!(warps >= 3, "{warps}"); + } + #[test] fn painted_and_sketched_follows_textures_with_one_effect_per_style() { let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect(); 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<String>, /// Character style (Character Styles panel) these attributes come from; None = Normal. diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 866d9b32f3..84991392b2 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -116,7 +116,7 @@ fn lengths_of(id: &str) -> Lengths { } } -const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; +const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds, and images bend as 10 × 10 cells of two triangles unless the pieces would make the art pass 2,000,000 objects or the image's frame keeps its shape past the anchor budget), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}"; const DT: &[&str] = &["Effect", "Distort & Transform"]; 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<Effect> { + 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<NodeId> = 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<Node> = 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::<Vec<_>>(); + 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 92ee086147..8591bbab2c 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -96,7 +96,7 @@ pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; pub use trap::TRAP; pub use vectorcraft_three_d::Mesh; -pub use warp::{WarpStyle, warp_point}; +pub use warp::{WARP_NET, WarpNet, WarpStyle, map_version, warp_point, warp_version}; mod catalog; mod geometry; diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index 7785aff335..1e3c8c1d31 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -31,7 +31,8 @@ pub fn merged_params(id: &str, params: &Value) -> Value { /// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its /// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of /// then) become the current ones that show exactly the same, unless those are given too, which win. -/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°). +/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°), and a Warp's +/// map `version` given as a whole float is stored as an integer. pub fn upgrade_params(id: &str, params: &Value) -> Value { match params { Value::Object(m) => { @@ -39,6 +40,7 @@ pub fn upgrade_params(id: &str, params: &Value) -> Value { upgrade_perspective(id, &mut m); upgrade_light(id, &mut m); upgrade_scribble(id, &mut m); + upgrade_warp_version(id, &mut m); Value::Object(m) } other => other.clone(), @@ -71,6 +73,19 @@ fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) { } } +/// A Warp's map `version` given as a whole float (`1.0`) is stored as the integer. +fn upgrade_warp_version(id: &str, m: &mut Map<String, Value>) { + if !id.starts_with("warp.") { + return; + } + if let Some(v) = m.get_mut("version") + && !v.is_u64() + && let Some(n) = crate::warp::warp_version(v) + { + *v = json!(n); + } +} + fn upgrade_scribble(id: &str, m: &mut Map<String, Value>) { if id != "stylize.scribble" { return; @@ -91,7 +106,15 @@ pub fn new_effect(id: &str, params: &Value) -> Option<Effect> { if !catalog_index().contains_key(id) { vectorcraft_plugins::effect::installed(id)?; } - Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) + let mut params = merged_params(id, params); + // A new Warp takes its style's Bézier net unless a map is given; the catalog's defaults carry + // no version, so warps saved without one keep the earlier map. + if id.starts_with("warp.") + && let Value::Object(m) = &mut params + { + m.entry("version").or_insert(json!(crate::warp::WARP_NET)); + } + Some(Effect { id: id.to_string(), params, visible: true }) } /// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index c629cc10ca..483ac44d6e 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -4,19 +4,22 @@ //! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths //! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect //! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a -//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects -//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without +//! warp bends a line of type as a whole. An image is clipped by its reshaped frame, and a Warp +//! effect bends its pixels with the frame ([`cut_warped_images`]). Raster effects (shadows, glows, blur, +//! feather) stay on the result for the renderer and exporters. Type without //! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those //! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and //! 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}; use vectorcraft_doc::live::Hooks; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind}; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes}; use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params}; @@ -548,7 +551,14 @@ fn as_art_opts( let mut art = match &n.kind { NodeKind::Image(im) => { let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); - let image = Node::new(n.id, n.kind.clone()); + let mut image = Node::new(n.id, n.kind.clone()); + // Its pixels draw as the image's do: colorized by a grayscale image's fill color, and + // smoothed or not. + { + let mut e = image.extras_mut(); + e.colorize = n.extras().colorize.clone(); + e.pixelated = n.extras().pixelated; + } let clipped = || { let mut clip = Node::path(n.id, frame.clone(), Appearance::default()); if let NodeKind::Path { clipping, .. } = &mut clip.kind { @@ -580,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). @@ -598,9 +608,29 @@ fn as_art_opts( 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); @@ -632,9 +662,9 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { /// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as /// its reference box and the art's seed (a random Transform moves the pieces together). Images and -/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry -/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The -/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path +/// symbol instances follow a Transform effect the same way ([`transform_placed`]), and images +/// follow a Warp effect as a raster mesh warp ([`cut_warped_images`]); other geometry effects leave them +/// as they are. The paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path /// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns /// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one /// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]). @@ -677,57 +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) = art.geometric_bounds() else { break }; + 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<usize>, + apart: bool, + doc: Option<&'a Document>, + wholes: &'a HashMap<NodeId, bool>, +} + +/// 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<crate::Carry>, +} + +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 { @@ -739,24 +863,68 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget } let mut groups = vec![]; if follows { - group_marks(art, &mut vec![], &HashSet::new(), &mut groups); + group_marks(art, &mut vec![], &HashSet::new(), None, &mut groups); + // 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.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). + // The ids of the paths that kept their shape (past the budget), and their places in the + // 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; let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. let before = Budget::of(&path); @@ -766,15 +934,26 @@ 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 { + *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; @@ -782,19 +961,28 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget 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); + *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); } @@ -804,27 +992,233 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget if boxes { rebase_leaves(art, &mut leaves.into_iter()); } + // 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) = warp + && let Some(warped) = warped.as_deref_mut() + { + 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, 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 held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1; + 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), art.geometric_bounds(), 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 + } } } - 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<NodeId>, + kept: &'a HashSet<NodeId>, +} + +/// 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<NodeId>) { + 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<NodeId>) -> 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 @@ -1045,7 +1439,9 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling let mut copy = leaf.clone(); copy.transform(xf, scaling); // 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; } @@ -1445,17 +1841,25 @@ type GroupMark = (Vec<usize>, Option<Rect>, bool); /// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or /// compound path below it that those bounds measure is in `kept`, measured in one walk. Each group below `n` that -/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. -fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut Vec<GroupMark>) -> (Option<Rect>, bool) { +/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. An image that `images` +/// lists is measured by the pieces it will be cut into ([`WarpedImages::image_bounds`]). +fn group_marks( + n: &Node, + at: &mut Vec<usize>, + kept: &HashSet<NodeId>, + images: Option<&WarpedImages>, + out: &mut Vec<GroupMark>, +) -> (Option<Rect>, bool) { match &n.kind { NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => (n.geometric_bounds(), kept.contains(&n.id)), + NodeKind::Image(_) => (images.map_or_else(|| n.geometric_bounds(), |w| w.image_bounds(n)), false), NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } if !n.extras().type_outlines => { let layer = matches!(n.kind, NodeKind::Layer { .. }); let skip = children.len().saturating_sub(n.editing_children().len()); let (mut bounds, mut held) = (None, false); for (i, c) in children.iter().enumerate() { at.push(i); - let (b, h) = group_marks(c, at, kept, out); + let (b, h) = group_marks(c, at, kept, images, out); if is_member_group(c) && (c.appearance.has_positioned_gradient() || c.appearance.has_unplaced_gradient()) { out.push((at.clone(), b, h)); } @@ -1577,17 +1981,239 @@ 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). +pub(crate) const WARP_IMAGE_CELLS: usize = 10; + +/// A Warp effect's point map, shared by the images it bends. +pub(crate) type WarpMap = Rc<dyn Fn(Point) -> Point>; + +/// One image a Warp effect bent: the image's own key, and each warp it took with the placement +/// the image had when it took it, in order. +type WarpedImage = (String, Vec<(WarpMap, Affine)>); + +/// The images Warp effects bend while [`reshape_leaves_apart`] runs the effects. Each such image's +/// key is replaced by a key of this table ([`WarpedImages::add`]), which a copy a later Transform +/// effect makes keeps. The images are cut into pieces once, after the last effect +/// ([`cut_warped_images`]): the effects after a Warp reshape the image's frame, not each piece, +/// and a later Warp or Transform moves the one set of pieces. +pub(crate) struct WarpedImages { + /// What this table's keys start with: no image key in the art starts with it + /// ([`WarpedImages::reserve`]). + prefix: String, + /// The prefix was checked against the art's image keys. + reserved: bool, + images: Vec<WarpedImage>, +} + +impl WarpedImages { + pub(crate) fn new() -> Self { + static TABLES: AtomicUsize = AtomicUsize::new(0); + Self { prefix: format!("\0warp{}:", TABLES.fetch_add(1, Ordering::Relaxed)), reserved: false, images: vec![] } + } + + /// Before the first image of `art` is marked: lengthen the prefix until no image key in `art` + /// starts with it, so no key from the document is read as one of this table's. + pub(crate) fn reserve(&mut self, art: &Node) { + if self.reserved { + return; + } + self.reserved = true; + let mut keys = vec![]; + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + keys.push(im.key.clone()); + } + }); + while keys.iter().any(|k| k.starts_with(self.prefix.as_str())) { + self.prefix.push(':'); + } + } + + /// What this table's keys start with. + #[cfg(test)] + pub(crate) fn prefix(&self) -> &str { + &self.prefix + } + + /// The image that `key`, a key of this table, stands for. + fn get(&self, key: &str) -> Option<&WarpedImage> { + self.images.get(key.strip_prefix(self.prefix.as_str())?.parse::<usize>().ok()?) + } + + /// Record that the image with key `key`, placed by `xf`, takes warp `f`; returns the image's + /// new key. + fn add(&mut self, key: &str, xf: Affine, f: &WarpMap) -> String { + let (own, mut warps) = self.get(key).cloned().unwrap_or_else(|| (key.to_string(), vec![])); + warps.push((f.clone(), xf)); + self.images.push((own, warps)); + format!("{}{}", self.prefix, self.images.len() - 1) + } + + /// Does the art `n` have room, within `max` nodes, for the pieces of every image under it that + /// this table lists? + fn fits(&self, n: &Node, max: usize) -> bool { + let mut count = 0usize; + n.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind + && self.get(&im.key).is_some() + { + count += 1; + } + }); + vectorcraft_doc::live::mesh_warp_cost(WARP_IMAGE_CELLS).saturating_mul(count) <= max.saturating_sub(n.count()) + } + + /// The geometric bounds of `art` as it draws after the effects so far: each image this table + /// lists measured by the pieces the warps and moves it took would cut it into, when the art + /// has room for them ([`cut_warped_images`]). + pub(crate) fn bounds(&self, art: &Node) -> Option<Rect> { + if self.images.is_empty() || !self.fits(art, crate::distort::MAX_COPY_OBJECTS) { + return art.geometric_bounds(); + } + // The pieces the images would be cut into now: each group of pieces is shown or hidden as + // its image is, and hidden objects are left out of a layer's bounds. + let mut probe = art.clone(); + restore_warped_images(&mut probe, self, true); + probe.geometric_bounds() + } + + /// The geometric bounds of image `n`: when this table lists it, measured by the pieces the + /// warps and moves it took would cut it into. + fn image_bounds(&self, n: &Node) -> Option<Rect> { + let NodeKind::Image(im) = &n.kind else { return n.geometric_bounds() }; + if self.get(&im.key).is_none() { + return n.geometric_bounds(); + } + let mut probe = Node::group(n.id, vec![Arc::new(n.clone())]); + restore_warped_images(&mut probe, self, true); + probe.geometric_bounds() + } + + /// This table when the art `art` has room for the pieces of the images it lists, so that a + /// measure of the art takes them bent ([`WarpedImages::bounds`]); `None` otherwise. + fn when_cut(&self, art: &Node) -> Option<&Self> { + (!self.images.is_empty() && self.fits(art, crate::distort::MAX_COPY_OBJECTS)).then_some(self) + } +} + +/// Record in `warped` that each image under `n` takes warp `f`, walking the groups and layers +/// [`for_each_leaf`] walks and counting the paths it passes in `at`. `kept` holds the places of +/// the paths that kept their shape (past the budget), in the order [`for_each_leaf`] visits the +/// paths of the art: an image in a clip group whose clipping art holds one (`held`) does not take +/// the warp. +pub(crate) fn mark_warped_images(n: &mut Node, f: &WarpMap, kept: &HashSet<usize>, held: bool, at: &mut usize, warped: &mut WarpedImages) { + let (NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }) = &mut n.kind else { return }; + let mut held = held; + // A clip group's first member is its clipping art: when a path in it kept its shape, it and + // the members it clips keep theirs. + if *clip && let Some(first) = children.first() { + let start = *at; + held |= (start..start + leaf_count(first)).any(|p| kept.contains(&p)); + } + for c in children.iter_mut() { + match &c.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => *at += 1, + NodeKind::Group { .. } | NodeKind::Layer { .. } => mark_warped_images(Arc::make_mut(c), f, kept, held, at, warped), + NodeKind::Image(_) if !held => { + if let NodeKind::Image(im) = &mut Arc::make_mut(c).kind { + im.key = warped.add(&im.key, im.xf, f); + } + } + _ => {} + } + } +} + +/// Give each image under `n` that `warped` lists its own key back, and cut it into the pieces of +/// a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`], each piece drawn as the image +/// is, smoothed or not) through the warps it took and the moves between and after them +/// ([`warped_map`]). The art keeps to [`crate::distort::MAX_COPY_OBJECTS`] nodes: when the pieces +/// would pass it, no image is cut. An image whose placement has no inverse is not cut either. +fn cut_warped_images(n: &mut Node, warped: &WarpedImages) { + cut_warped_images_within(n, warped, crate::distort::MAX_COPY_OBJECTS); +} + +/// [`cut_warped_images`] with the art keeping to `max` nodes. +pub(crate) fn cut_warped_images_within(n: &mut Node, warped: &WarpedImages, max: usize) { + if warped.images.is_empty() { + return; + } + let cut = warped.fits(n, max); + restore_warped_images(n, warped, cut); +} + +/// The walk of [`cut_warped_images`]: each listed image gets its key back, and with `cut` is cut +/// into pieces. +fn restore_warped_images(n: &mut Node, warped: &WarpedImages, cut: bool) { + let Some(children) = n.children_mut() else { return }; + for c in children.iter_mut() { + let NodeKind::Image(im) = &c.kind else { + if c.children().is_some() { + restore_warped_images(Arc::make_mut(c), warped, cut); + } + continue; + }; + let Some((key, warps)) = warped.get(&im.key) else { continue }; + let mut image = (**c).clone(); + let NodeKind::Image(own) = &mut image.kind else { continue }; + own.key = key.clone(); + // The pieces start from the image as it was placed when it took its first warp. + let first = warps.first().map(|(_, at)| ImageObject { xf: *at, ..(**own).clone() }); + let map = warped_map(warps, own.xf); + *c = Arc::new(match (first, map) { + (Some(first), Some(map)) if cut => { + let pixelated = image.extras().pixelated; + vectorcraft_doc::live::mesh_warp_image(&image, &first, &*map, WARP_IMAGE_CELLS, pixelated, false) + } + _ => image, + }); + } +} + +/// The map that takes a point of an image placed by the first of `warps`' placements to where the +/// warps and the moves between them take it, for an image now placed by `placed`: each warp in +/// turn, then the move from the placement the image had when it took that warp to the one it had +/// at the next, or to `placed` after the last. `None` when a placement has no inverse. +fn warped_map(warps: &[(WarpMap, Affine)], placed: Affine) -> Option<Box<dyn Fn(Point) -> Point>> { + let mut steps: Vec<(WarpMap, Affine)> = Vec::with_capacity(warps.len()); + for (i, (f, at)) in warps.iter().enumerate() { + let det = at.determinant(); + if !det.is_finite() || det == 0.0 { + return None; + } + let next = warps.get(i + 1).map_or(placed, |(_, at)| *at); + let m = next * at.inverse(); + if !m.is_finite() { + return None; + } + steps.push((f.clone(), m)); + } + Some(Box::new(move |p| steps.iter().fold(p, |q, (f, m)| *m * f(q)))) +} + +/// How many paths [`for_each_leaf`] visits in `n`. +fn leaf_count(n: &Node) -> usize { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => 1, + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().map(|c| leaf_count(c)).sum(), + _ => 0, + } } /// An object that a Transform effect places through its transform rather than reshapes: an image @@ -1600,11 +2226,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(); @@ -1623,6 +2249,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 c73d76e2c8..ec9563f05c 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -29,7 +29,7 @@ const MAX_PAINT_COPIES: usize = 256 << 20; /// The most face and box tests a group's transparent members' faces take together to rejoin their /// members' last runs; past them each starts a run of its own. -const MAX_OVERLAP_TESTS: usize = 1 << 22; +pub(crate) const MAX_OVERLAP_TESTS: usize = 1 << 22; /// The width of the stroke around each face that covers the seams between faces. const SEAM_WIDTH: f64 = 0.75; @@ -628,14 +628,14 @@ pub(crate) fn scoped<'a>(id: NodeId, surfaces: Vec<(&'a [Scope], Arc<Node>)>, le /// What a face can paint, to tell whether two faces overlap: the convex hull of the points and /// control points of its outline, which holds the outline, and the hull's box grown by half the /// seam stroke. -struct Coverage { - bounds: Rect, +pub(crate) struct Coverage { + pub(crate) bounds: Rect, hull: Vec<Point>, } impl Coverage { /// `None` for a face with no outline and no box. - fn of(face: &Node) -> Option<Coverage> { + pub(crate) fn of(face: &Node) -> Option<Coverage> { let points: Vec<Point> = match face.path_data() { Some(path) => path .to_bezpath() @@ -663,7 +663,7 @@ impl Coverage { /// Do the two faces' hulls, each grown by half the seam stroke, overlap? A test of the /// separating axes of their edges and of the page's axes: `true` whenever they overlap, and /// sometimes near a corner when they don't. - fn meets(&self, other: &Coverage) -> bool { + pub(crate) fn meets(&self, other: &Coverage) -> bool { fn normals(hull: &[Point]) -> impl Iterator<Item = Vec2> + '_ { (0..hull.len()).filter_map(|i| { let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?); @@ -710,7 +710,7 @@ fn convex_hull(mut points: Vec<Point>) -> Vec<Point> { } /// Do boxes `a` and `b` overlap with some area? -fn overlaps(a: Rect, b: Rect) -> bool { +pub(crate) fn overlaps(a: Rect, b: Rect) -> bool { a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 8d9ff9eea8..538d2a5028 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -700,10 +700,11 @@ type ShadowAndSurfaces = (Vec<Arc<Node>>, Vec<Arc<Node>>); /// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a /// solid from the path for the top visible fill and one from the outline of each visible stroke, /// each in its own paint. When every one is opaque with normal blending and no colored light makes -/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where -/// depths tie, as for the caps turned toward the viewer, in the order of the appearance, and with -/// caps, a shape's fill walls before the faces of its covering strokes that overlap them: -/// [`draw_order`]); +/// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as +/// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's +/// fill walls before the faces of its covering strokes that overlap them: [`draw_order`]), the +/// faces of a solid in a gradient with transparent stops in runs that carry its opacity mask +/// ([`in_runs`]); /// otherwise each solid is a surface of its own, in that order. A member's opacity mask and /// blending mode, and those of a group it is in, apply to the surfaces of the members it holds /// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it @@ -856,19 +857,21 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod return Some(masked(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect())); } // One order takes a plain group of faces from each solid, one face for each facet. With - // colored lights a surface can instead be one clipped composite of all its facets, or carry an - // opacity mask, and then it stays whole. - let plain = surfaces.iter().all(|(depths, s, _)| { - s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()) - }); + // colored lights a surface can instead be one clipped composite of all its facets. A composite + // has no faces to order, so when any surface is one, every surface of the solid, masked ones + // included, draws whole in member order. + let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); if !opaque || !plain { return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect()); } + // A surface in a gradient with transparent stops carries an opacity mask: its faces go in runs + // ([`in_runs`]), and each run carries the mask. + let masks: Vec<Option<Box<OpacityMask>>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect(); let (mut placed, mut faces) = (vec![], vec![]); - for (depths, s, _) in surfaces { + for (surface, (depths, s, _)) in surfaces.into_iter().enumerate() { if let NodeKind::Group { children, .. } = s.kind { placed.extend(depths); - faces.extend(children.into_iter().map(Some)); + faces.extend(children.into_iter().map(|face| Some((surface, face)))); } } let sealed = match fx.id.as_str() { @@ -877,7 +880,69 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod _ => extrude_options(&p).cap, }; let order = draw_order(&placed, sealed); - Some(vec![Arc::new(Node::group(n.id, order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect()))]) + let faces: Vec<(usize, Arc<Node>)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect(); + let children = if masks.iter().all(Option::is_none) { faces.into_iter().map(|(_, face)| face).collect() } else { in_runs(n.id, faces, &masks) }; + Some(vec![Arc::new(Node::group(n.id, children))]) +} + +/// `faces` in their back-to-front order, each with the index of its surface, where some surfaces +/// carry an opacity mask (`masks`, by surface). A face of a surface without one goes in the order +/// on its own. The faces of a masked surface share a run, one group with the surface's mask. Such a +/// face joins its surface's last run unless a face drawn after that run overlaps it: only then does +/// the surface split, where one group can't be both behind and in front of another face. +fn in_runs(id: NodeId, faces: Vec<(usize, Arc<Node>)>, masks: &[Option<Box<OpacityMask>>]) -> Vec<Arc<Node>> { + use crate::revolve::{Coverage, overlaps}; + struct Run { + surface: Option<usize>, + bounds: Rect, + faces: Vec<Arc<Node>>, + cover: Vec<Coverage>, + } + let mut runs: Vec<Run> = vec![]; + let mut last_run = vec![None; masks.len()]; + // Past this many tests a face starts a run of its own, which never draws out of order. + let mut budget = crate::revolve::MAX_OVERLAP_TESTS; + for (surface, face) in faces { + let Some(cover) = Coverage::of(&face) else { + // A face with no outline and no box overlaps nothing. + runs.push(Run { surface: None, bounds: Rect::ZERO, faces: vec![face], cover: vec![] }); + continue; + }; + if masks.get(surface).is_none_or(Option::is_none) { + runs.push(Run { surface: None, bounds: cover.bounds, faces: vec![face], cover: vec![cover] }); + continue; + } + let mut clear = |r: &Run| { + budget = budget.saturating_sub(1); + if !overlaps(r.bounds, cover.bounds) { + return budget > 0; + } + budget = budget.saturating_sub(r.cover.len()); + budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover)) + }; + let joins = last_run.get(surface).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear))); + match joins.and_then(|i| runs.get_mut(i)) { + Some(run) => { + run.bounds = run.bounds.union(cover.bounds); + run.faces.push(face); + run.cover.push(cover); + } + None => { + if let Some(last) = last_run.get_mut(surface) { + *last = Some(runs.len()); + } + runs.push(Run { surface: Some(surface), bounds: cover.bounds, faces: vec![face], cover: vec![cover] }); + } + } + } + runs.into_iter() + .filter_map(|mut r| { + let Some(mask) = r.surface.and_then(|s| masks.get(s)).cloned().flatten() else { return r.faces.pop() }; + let mut group = Node::group(id, r.faces); + group.extras_mut().mask = Some(mask); + Some(Arc::new(group)) + }) + .collect() } fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index c960bbe671..1efe826c75 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -415,15 +415,16 @@ fn a_translucent_stroke_is_a_surface_of_its_own() { } /// With a light that is not white, a fill in a gradient with transparent stops is drawn with an -/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their -/// own, and the mask stays on the fill's. Under a white light the same object is one surface. +/// opacity mask: its faces still take their places in the one order of the fill's and the +/// stroke's faces, in runs that each carry the mask. Under a white light the faces carry the +/// gradient itself and no mask. #[test] -fn a_masked_surface_under_a_colored_light_stays_whole() { +fn a_masked_surface_under_a_colored_light_keeps_the_shared_face_order() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; let mut half = GradientStop::new(1.0, RED); half.opacity = 0.5; let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half])); - for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] { + for (lights, masked) in [(json!({"lights": [{"color": "#ff8080"}]}), true), (json!({}), false)] { let mut n = stroked_rect(100.0); if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { f.paint = Paint::Gradient(Box::new(gradient.clone())); @@ -431,8 +432,10 @@ fn a_masked_surface_under_a_colored_light_stays_whole() { n.appearance.effects.push(fx(EXTRUDE, lights.clone())); let art = solid_art(&n).unwrap(); let drawn = art.children().unwrap(); - assert_eq!(drawn.len(), surfaces, "{lights}"); - assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}"); + assert_eq!(drawn.len(), 1, "{lights}: one order of faces"); + let runs: Vec<&Arc<Node>> = drawn[0].children().unwrap().iter().filter(|c| c.extras().mask.is_some()).collect(); + assert_eq!(!runs.is_empty(), masked, "{lights}"); + assert!(runs.iter().all(|r| r.children().unwrap().iter().all(|f| !blue(f))), "{lights}: the mask stays on the fill's faces"); } } @@ -847,7 +850,9 @@ fn classic_shading_keeps_a_gradients_transparency() { } let art = solid_art(&n).unwrap(); let surface = &art.children().unwrap()[0]; - assert_eq!(surface.extras().mask.is_some(), masked, "{end}"); + let mut has_mask = false; + surface.walk(&mut |c| has_mask |= c.extras().mask.is_some()); + assert_eq!(has_mask, masked, "{end}"); let mut opaque = true; surface.walk(&mut |c| { for item in &c.appearance.items { @@ -862,6 +867,67 @@ fn classic_shading_keeps_a_gradients_transparency() { } } +/// A group member in a gradient with transparent stops keeps the shared back-to-front order of +/// every member's faces: its faces go in runs that carry its opacity mask, and the other member's +/// faces are drawn between them where they are in front of some and behind others. Here a square +/// frame in the gradient holds a square in its hole: the frame's inner walls are behind the square +/// and its front cap is in front of the square's walls. +#[test] +fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let square = |b: &mut BezPath, x0: f64, x1: f64, clockwise: bool| { + let corners = [(x0, x0), (x1, x0), (x1, x1), (x0, x1)]; + let mut points = corners.to_vec(); + if !clockwise { + points.reverse(); + } + b.move_to(points[0]); + for p in &points[1..] { + b.line_to(*p); + } + b.close_path(); + }; + let art = |id: &str, params: &Value, end_opacity: f32| { + let mut end = GradientStop::new(1.0, Color::BLACK); + end.opacity = end_opacity; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), end])))); + let mut b = BezPath::new(); + square(&mut b, 100.0, 300.0, true); + square(&mut b, 150.0, 250.0, false); + let frame = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0)); + let mut b = BezPath::new(); + square(&mut b, 185.0, 215.0, true); + let inner = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0)); + let mut g = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(inner)]); + g.appearance.effects.push(fx(id, params.clone())); + solid_art(&g).unwrap_or_else(|| panic!("{id}")) + }; + // The drawing order: `M` for a group with an opacity mask, `F` for a face outside one. + fn visit(n: &Node, order: &mut String) { + if n.extras().mask.is_some() { + order.push('M'); + return; + } + if n.path_data().is_some() { + order.push('F'); + } + for c in n.children().into_iter().flatten() { + visit(c, order); + } + } + let order = |art: &Node| { + let mut order = String::new(); + visit(art, &mut order); + order + }; + for (id, params) in [(CLASSIC_EXTRUDE, json!({})), (EXTRUDE, json!({"lights": [{"color": "#ff8080"}]}))] { + assert!(!order(&art(id, ¶ms, 1.0)).contains('M'), "{id}: an opaque gradient needs no mask"); + let masked = order(&art(id, ¶ms, 0.5)); + let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap()); + assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "{id}: faces between the member's runs: {masked}"); + } +} + #[test] fn classic_parameters_are_checked() { let n = classic(json!({})); diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 4bddf80b0a..ec54be20cd 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -367,9 +367,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,35 +474,60 @@ 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<f64> { + let offsets: Vec<f64> = 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<usize>| 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. pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec<GradientMesh> { + let g = &*g.unrolled(); let across = st.gradient_mode == StrokeGradientMode::Across; let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); + let at_stops = mesh_marks(g); // Along: a column at each stop (colours are linear between columns), close enough to look smooth. - let marks: Vec<f64> = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() }; + let marks: Vec<f64> = if across { vec![] } else { at_stops.clone() }; 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<f64> = 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<f64> = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(at_stops).collect() } else { vec![0.5] }; us.sort_by(f64::total_cmp); us.dedup_by(|a, b| (*a - *b).abs() < 1e-6); strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt) diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index ea966ea076..3745e4a6f6 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -286,6 +286,65 @@ 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}"); + } +} + /// #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/tests.rs b/crates/effects/src/tests.rs index 532251bd73..e63b38a462 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -209,6 +209,57 @@ fn warp_arc_bends_the_top_edge() { assert!(b.x0 < -1.0 || b.x1 > 101.0, "{b:?}"); } +/// A Warp of `version` 1 bends its bounds as its style's Bézier net; one without a version, as +/// saved before the version existed, draws as it always did. Flag at 60 % on a 200 × 100 box: the +/// net moves the top edge up to 6 × 0.6 × 100 × max u(1 - u)(1 - 2u) = 34.64 out, the earlier map +/// moves it 0.6 × 25 = 15. +#[test] +fn a_warp_draws_with_the_map_its_version_names() { + let rect = shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 120.0)); + // u(1 - u)(1 - 2u) peaks at √3 / 18. + let peak = 360.0 * 3f64.sqrt() / 18.0; + for (version, out) in [(json!(1), peak), (json!(null), 15.0), (json!(0), 15.0)] { + let b = run("warp.flag", json!({"bend": 60, "version": version}), &rect).bounds().unwrap(); + assert!(close(b.y0, 20.0 - out, 0.1) && close(b.y1, 120.0 + out, 0.1), "version {version}: {b:?}"); + assert!(close(b.x0, 10.0, 1e-9) && close(b.x1, 210.0, 1e-9), "version {version}: {b:?}"); + } +} + +/// A straight line has a box with no width or no height, and the net bends it as the middle of +/// the patch along that side: under Arc a vertical line stays vertical, and under Bulge, Fisheye, +/// Inflate and Squeeze a horizontal line stays straight. +#[test] +fn a_warp_bends_a_flat_box_as_the_middle_of_its_patch() { + let anchors = |p: &PathData| p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect::<Vec<_>>(); + let vertical = shapes::line(Point::new(100.0, 0.0), Point::new(100.0, 100.0)); + let out = run("warp.arc", json!({"bend": 50, "version": 1}), &vertical); + assert!(anchors(&out).iter().all(|a| close(a.p.x, 100.0, 1e-9) && close(a.h_in.x, 100.0, 1e-9) && close(a.h_out.x, 100.0, 1e-9)), "{out:?}"); + let horizontal = shapes::line(Point::new(0.0, 50.0), Point::new(200.0, 50.0)); + for style in ["bulge", "fisheye", "inflate", "squeeze"] { + let out = run(&format!("warp.{style}"), json!({"bend": 50, "version": 1}), &horizontal); + assert!( + anchors(&out).iter().all(|a| close(a.p.y, 50.0, 1e-9) && close(a.h_in.y, 50.0, 1e-9) && close(a.h_out.y, 50.0, 1e-9)), + "{style}: {out:?}" + ); + } +} + +/// A new Warp takes the nets' version unless one is given, and the catalog's defaults carry none, +/// so a saved warp's params don't gain one. +#[test] +fn new_warps_take_the_nets_version() { + for (s, _) in WARP_STYLES { + let id = format!("warp.{s}"); + let Some(e) = crate::new_effect(&id, &json!({"bend": 20})) else { panic!("{id}") }; + assert_eq!(e.params["version"], json!(crate::WARP_NET), "{id}"); + let Some(e) = crate::new_effect(&id, &json!({"version": 0})) else { panic!("{id}") }; + assert_eq!(e.params["version"], json!(0), "{id}"); + assert!(crate::merged_params(&id, &json!({"bend": 20})).get("version").is_none(), "{id}"); + } + let Some(e) = crate::new_effect("distort.twist", &json!({})) else { panic!("twist") }; + assert!(e.params.get("version").is_none()); +} + #[test] fn transform_copies_and_move() { let out = run("distort.transform", json!({"moveH": 20, "copies": 3}), &square()); @@ -318,6 +369,317 @@ fn stacked_transform_copies_stay_within_the_image_budget() { assert_eq!(count(&evaluate_container(None, &g).unwrap()), 1001); } +/// A 40 × 40 px image placed at (0, 0) at one point a pixel, with id `id`. +fn warp_test_image(id: u64) -> std::sync::Arc<Node> { + use vectorcraft_doc::{ImageObject, NodeId}; + let im = ImageObject { key: "k".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + std::sync::Arc::new(Node::new(NodeId(id), NodeKind::Image(Box::new(im)))) +} + +/// How many images `art` holds, and the keys they have. +fn warp_test_images(art: &Node) -> (usize, std::collections::BTreeSet<String>) { + let (mut n, mut keys) = (0, std::collections::BTreeSet::new()); + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + n += 1; + keys.insert(im.key.clone()); + } + }); + (n, keys) +} + +#[test] +fn warped_images_stay_within_the_object_budget() { + use std::rc::Rc; + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1)); + let cost = vectorcraft_doc::live::mesh_warp_cost(crate::reshape::WARP_IMAGE_CELLS); + let none = std::collections::HashSet::new(); + let k = || std::collections::BTreeSet::from(["k".to_string()]); + let warp = |art: &mut Node, warps: usize| { + let mut warped = crate::reshape::WarpedImages::new(); + for _ in 0..warps { + crate::reshape::mark_warped_images(art, &lift, &none, false, &mut 0, &mut warped); + } + warped + }; + // The art has room for one image's pieces only: neither image is cut, and each gets its own + // key back. + let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]); + let warped = warp(&mut art, 1); + let max = art.count() + cost; + crate::reshape::cut_warped_images_within(&mut art, &warped, max); + assert_eq!(warp_test_images(&art), (2, k())); + // With room for both, each is cut into 200 pieces once, however many warps it took. + let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]); + let warped = warp(&mut art, 3); + let max = art.count() + 2 * cost; + crate::reshape::cut_warped_images_within(&mut art, &warped, max); + assert_eq!(warp_test_images(&art), (400, k())); + // Images clipped by their frames: the second frame (the second path) kept its shape, so its + // image stays whole, and the first is cut. The third's frame is a group of two paths, the + // second of them kept, with an image after them, and its image sits in a group of its own: + // both stay whole too. + let frame = |id: u64| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Default::default())); + let clip_group = |id: u64, children: Vec<Arc<Node>>| Arc::new(Node::new(NodeId(id), NodeKind::Group { children, clip: true })); + let nested = clip_group( + 4, + vec![ + Arc::new(Node::group(NodeId(4), vec![frame(4), frame(4), warp_test_image(5)])), + Arc::new(Node::group(NodeId(4), vec![warp_test_image(4)])), + ], + ); + let mut art = + Node::group(NodeId(1), vec![clip_group(2, vec![frame(2), warp_test_image(2)]), clip_group(3, vec![frame(3), warp_test_image(3)]), nested]); + let (mut at, mut warped) = (0, crate::reshape::WarpedImages::new()); + crate::reshape::mark_warped_images(&mut art, &lift, &std::collections::HashSet::from([1, 3]), false, &mut at, &mut warped); + assert_eq!(at, 4); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(warp_test_images(&art), (203, k())); +} + +/// An image key from the document that looks like a key of the table is not read as one, and a +/// hidden image stays out of the bounds the later effects take, as it does before it is warped. +#[test] +fn warped_images_keep_document_keys_and_hidden_images_apart() { + use std::rc::Rc; + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1)); + let none = std::collections::HashSet::new(); + let mut warped = crate::reshape::WarpedImages::new(); + let lookalike = format!("{}0", warped.prefix()); + let mut b = (*warp_test_image(3)).clone(); + if let NodeKind::Image(im) = &mut b.kind { + im.key = lookalike.clone(); + } + let mut art = Node::group(NodeId(1), vec![warp_test_image(2), Arc::new(b)]); + warped.reserve(&art); + crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(warp_test_images(&art), (400, std::collections::BTreeSet::from(["k".to_string(), lookalike]))); + // A layer of a 10 × 10 square and a hidden 40 × 40 image: the warped image is not measured. + let square = Arc::new(Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default())); + let mut hidden = (*warp_test_image(5)).clone(); + hidden.visible = false; + let mut layer = Node::layer(NodeId(6), "Layer 1", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(children) = layer.children_mut() { + *children = vec![square, Arc::new(hidden)]; + } + let before = layer.geometric_bounds(); + let mut warped = crate::reshape::WarpedImages::new(); + warped.reserve(&layer); + crate::reshape::mark_warped_images(&mut layer, &lift, &none, false, &mut 0, &mut warped); + assert_eq!(warped.bounds(&layer), before); + // A shown image is measured by the pieces it is cut into. + let mut art = Node::group(NodeId(1), vec![warp_test_image(2)]); + let mut warped = crate::reshape::WarpedImages::new(); + warped.reserve(&art); + crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped); + let measured = warped.bounds(&art); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(measured, art.geometric_bounds()); + assert!(measured.is_some_and(|b| b.y0 < -3.9), "{measured:?}"); + // An image placed at a tiny scale (a placement with a tiny determinant) is cut too; one placed + // with no area is not. + for (scale, pieces) in [(1e-7, 200), (0.0, 1)] { + let mut image = (*warp_test_image(2)).clone(); + if let NodeKind::Image(im) = &mut image.kind { + im.xf = Affine::scale(scale); + } + let mut art = Node::group(NodeId(1), vec![Arc::new(image)]); + let mut warped = crate::reshape::WarpedImages::new(); + crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(warp_test_images(&art).0, pieces, "{scale}"); + } +} + +/// An image whose frame keeps its shape because the warp would pass the anchor budget keeps its +/// pixels as they are; within the budget, both bend. +#[test] +fn an_image_whose_frame_passes_the_budget_stays_whole() { + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let clipped = || { + let mut frame = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut frame.kind { + *clipping = true; + } + Node::new(NodeId(1), NodeKind::Group { children: vec![Arc::new(frame), warp_test_image(1)], clip: true }) + }; + let arc = fx("warp.arc", json!({"bend": 50, "version": 1})); + let k = || std::collections::BTreeSet::from(["k".to_string()]); + let mut art = clipped(); + crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget { anchors: 0, lines: 0 }, None); + assert_eq!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).map(|p| p.anchor_count()), Some(4)); + assert_eq!(warp_test_images(&art), (1, k())); + let mut art = clipped(); + crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget::EFFECTS, None); + assert!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).is_some_and(|p| p.anchor_count() > 4)); + assert_eq!(warp_test_images(&art), (200, k())); +} + +/// The first image under `n` (the first piece of a cut image). +fn first_image(n: &Node) -> Option<vectorcraft_doc::ImageObject> { + let mut first = None; + n.walk(&mut |c| { + if first.is_none() + && let NodeKind::Image(im) = &c.kind + { + first = Some((**im).clone()); + } + }); + first +} + +#[test] +fn effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces() { + let image = |effects: Vec<Effect>| { + let mut n = (*warp_test_image(1)).clone(); + n.appearance.effects = effects; + n + }; + // Each piece's clipping path, and the image's frame (the outer clip group's clipping path). + let clips = |art: &Node| { + let (mut pieces, mut frame) = (vec![], None); + art.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && let Some(path) = children.first().and_then(|p| p.path_data()) + { + if matches!(children.get(1).map(|i| &i.kind), Some(NodeKind::Image(_))) && path.anchor_count() == 3 { + pieces.push(path.anchor_count()); + } else if frame.is_none() { + frame = Some(path.anchor_count()); + } + } + }); + (pieces, frame) + }; + let arc = fx("warp.arc", json!({"bend": 50, "version": 1})); + for after in [ + fx("distort.roughen", json!({"size": 5, "detail": 10})), + fx("stylize.roundCorners", json!({"radius": 5})), + fx("path.offsetPath", json!({"offset": -2})), + ] { + let art = crate::reshape::reshape(None, &image(vec![arc.clone(), after.clone()]), None).unwrap(); + let (pieces, frame) = clips(&art); + assert_eq!(pieces.len(), 200, "{}", after.id); + assert!(frame.is_some_and(|n| n > 4), "{}: {frame:?}", after.id); + assert_eq!(warp_test_images(&art), (200, std::collections::BTreeSet::from(["k".to_string()])), "{}", after.id); + } + // Two warps cut the image once, through both maps; a Transform after them moves the pieces. + let flag = fx("warp.flag", json!({"bend": 30, "version": 1})); + let mv = fx("distort.transform", json!({"moveH": 100, "moveV": 0})); + let art = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone()]), None).unwrap(); + assert_eq!(warp_test_images(&art).0, 200); + let moved = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone(), mv]), None).unwrap(); + assert_eq!(warp_test_images(&moved).0, 200); + let box1 = Rect::new(0.0, 0.0, 40.0, 40.0); + let f1 = crate::warp::warp_map(box1, crate::warp::WarpStyle::Arc, &merged_params("warp.arc", &arc.params)); + // The flag bends the box the arc leaves, which the arc's frame measures. + let box2 = crate::reshape::reshape(None, &image(vec![arc.clone()]), None).unwrap().geometric_bounds().unwrap(); + let f2 = crate::warp::warp_map(box2, crate::warp::WarpStyle::Flag, &merged_params("warp.flag", &flag.params)); + // The first piece's corners: the pixels (0, 0), (4, 0) and (4, 4). + for (art, by) in [(&art, 0.0), (&moved, 100.0)] { + let piece = first_image(art).unwrap(); + for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] { + let want = f2(f1(p)) + vectorcraft_geom::Vec2::new(by, 0.0); + assert!((piece.xf * p).distance(want) < 1e-6, "{p:?}: {:?} {want:?}", piece.xf * p); + } + } +} + +/// A warp with no version (VectorCraft's earlier map) bends an image's pixels with that map, as it +/// bends the frame. +#[test] +fn an_images_pixels_take_the_map_their_warps_version_names() { + for version in [json!(null), json!(0), json!(1)] { + let mut params = json!({"bend": 50}); + if !version.is_null() { + params["version"] = version.clone(); + } + let mut n = (*warp_test_image(1)).clone(); + n.appearance.effects = vec![fx("warp.arc", params.clone())]; + let art = crate::reshape::reshape(None, &n, None).unwrap(); + let f = crate::warp::warp_map(Rect::new(0.0, 0.0, 40.0, 40.0), crate::warp::WarpStyle::Arc, &merged_params("warp.arc", ¶ms)); + let piece = first_image(&art).unwrap(); + for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] { + assert!((piece.xf * p).distance(f(p)) < 1e-6, "{version}: {p:?}"); + } + // The frame has an anchor where the map takes each corner. + let frame = art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).unwrap(); + for corner in [Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(40.0, 40.0), Point::new(0.0, 40.0)] { + let near = frame.subpaths.iter().flat_map(|s| &s.anchors).any(|a| a.p.distance(f(corner)) < 1e-6); + assert!(near, "{version}: {corner:?}"); + } + } +} + +/// Under a group's Warp effect, a nested group's own placed gradient follows the nested group's +/// bounds to where its image's pieces draw them. +#[test] +fn a_nested_groups_gradient_follows_its_warped_image() { + use std::sync::Arc; + + use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeId}; + 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 geom = GradientGeom { start: (5.0, 20.0).into(), end: (35.0, 20.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let mut inner = Node::group(NodeId(5), vec![warp_test_image(1)]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed))); + let square = Node::path(NodeId(2), shapes::rectangle(Rect::new(60.0, 0.0, 100.0, 40.0)), Appearance::default()); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(square)]); + g.appearance.effects.push(fx("warp.arc", json!({"bend": 50, "version": 1}))); + let art = crate::group::evaluate_container(None, &g).unwrap(); + assert_eq!(warp_test_images(&art).0, 200); + let (from, mut to) = (Rect::new(0.0, 0.0, 40.0, 40.0), None); + art.walk(&mut |c| { + if c.id == NodeId(5) && to.is_none() { + to = c.geometric_bounds(); + } + }); + let to = to.unwrap(); + assert!(to.y0 < from.y0 - 1.0, "the pieces draw the image bent: {to:?}"); + let follow = |p: Point| Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()); + let mut checked = 0; + art.walk(&mut |c| { + if c.id != NodeId(5) { + return; + } + for item in &c.appearance.items { + let Paint::Gradient(gp) = item.paint() else { continue }; + let got = gp.geom.unwrap(); + assert!(got.start.distance(follow(geom.start)) < 1e-6 && got.end.distance(follow(geom.end)) < 1e-6, "{got:?} in {to:?}"); + checked += 1; + } + }); + assert!(checked > 0); +} + +/// An image under a geometry effect other than a Warp draws as the image does: colorized by +/// a grayscale image's fill color, and smoothed or not. +#[test] +fn an_image_under_a_geometry_effect_keeps_its_color_and_smoothing() { + let mut n = (*warp_test_image(1)).clone(); + n.appearance.effects = vec![fx("distort.roughen", json!({"size": 5, "detail": 10}))]; + n.extras_mut().colorize = Some(Box::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))); + n.extras_mut().pixelated = true; + let art = crate::reshape::reshape(None, &n, None).unwrap(); + let mut seen = vec![]; + art.walk(&mut |c| { + if matches!(c.kind, NodeKind::Image(_)) { + seen.push((c.extras().colorize.clone(), c.extras().pixelated)); + } + }); + assert_eq!(seen, vec![(n.extras().colorize.clone(), true)]); +} + /// What `effects` make of `path` within `budget`. fn within(effects: &[Effect], path: &PathData, budget: Budget) -> PathData { apply_geometry_within(effects, path, path.bounds().unwrap(), &GeomContext::default(), budget) diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index 84f196a8b4..4b117d4142 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -1,25 +1,49 @@ //! Warp effects: 15 envelope styles plus horizontal/vertical perspective distortion. //! -//! Each style is a map on normalised box coordinates (x, y) ∈ [-1, 1]² (y down). The path is -//! split into short pieces whose control points are mapped (see `map_nonlinear`). +//! A warp with `version` [`WARP_NET`] is its style's Bézier patch over the box ([`WarpNet`]); +//! one without (saved before the version existed) is a map on normalized box coordinates +//! (x, y) ∈ [-1, 1]² (y down), [`warp_point`]. The path is split into short pieces whose control +//! points are mapped (see `map_nonlinear`). use serde_json::Value; use vectorcraft_geom::{PathData, Point, Rect}; use crate::util::{map_nonlinear_within, num, text}; -pub use vectorcraft_doc::live::{WarpStyle, warp_point}; +pub use vectorcraft_doc::live::{WARP_NET, WarpNet, WarpStyle, uses_net, warp_point, warp_version}; + +/// The map version warp params `p` ask for: their `version`, 0 (the first map) when absent. +pub fn map_version(p: &Value) -> u32 { + let v = num(p, "version", 0.0); + if v.is_finite() && v >= 0.0 { v.min(f64::from(u32::MAX)) as u32 } else { 0 } +} /// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]). pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData { + let piece = b.width().hypot(b.height()) / 24.0; + map_nonlinear_within(path, piece, max, warp_map(b, style, p)) +} + +/// The point map a warp of `style` with params `p` applies to box `b`: what [`warp`] moves each +/// point of a path to. +pub(crate) fn warp_map(b: Rect, style: WarpStyle, p: &Value) -> Box<dyn Fn(Point) -> Point> { let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0; let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0; let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0; let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical"); + if uses_net(map_version(p)) { + let net = WarpNet::new(style, bend, dh, dv, b.width(), b.height(), vertical); + let (w, h) = (b.width(), b.height()); + // A box with no width or no height takes the middle of the patch along that side. + return Box::new(move |q: Point| { + let (u, v) = (if w > 0.0 { (q.x - b.x0) / w } else { 0.5 }, if h > 0.0 { (q.y - b.y0) / h } else { 0.5 }); + b.origin() + net.at(u, v).to_vec2() + }); + } let c = b.center(); let hw = (b.width() / 2.0).max(1e-9); let hh = (b.height() / 2.0).max(1e-9); - let f = |q: Point| { + Box::new(move |q: Point| { let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh); let (x2, y2) = if vertical { let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x); @@ -28,18 +52,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - warp_point(style, bend, dh, dv, hh / hw, x, y) }; Point::new(c.x + x2 * hw, c.y + y2 * hh) - }; - let piece = b.width().hypot(b.height()) / 24.0; - map_nonlinear_within(path, piece, max, f) + }) } /// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with -/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the -/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline` -/// tests both sides of it). +/// no bend and no distortion. With the first map, Arc (`warp.arc`) takes a bend under 0.0001 % as +/// none: the threshold must match the one in [`warp_point`]'s Arc case +/// (`warp_bends_when_the_warp_moves_the_outline` tests both sides of it). With [`WarpNet`] any +/// bend moves the outline. pub(crate) fn bends(id: &str, p: &Value) -> bool { let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0; let bend = share("bend", 50.0); - let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 }; + let bent = if id == "warp.arc" && !uses_net(map_version(p)) { bend.abs() >= 1e-6 } else { bend != 0.0 }; bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0 } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 465025302f..a507686959 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -771,6 +771,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/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<CommandSpec> { - 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<Value> { }; 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<NodeId>, original: Option<NodeId>| { 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 (<https://help.arlon.com/what-is-a-weeding-border>). 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<CommandSpec> { + 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<f64> { + 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<usize>, spacing: Option<f64>) -> Result<usize> { + 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<NodeId>) -> Option<NodeId> { + 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<Value> { + 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<Rect>, &(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<Point>, + 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<Item = Segment> + '_ { + 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<Poly>, rule: FillRule }, + /// Everything its members draw. + Union(Vec<Region>), + /// What `content` draws inside `clip`. + Clip { clip: Box<Region>, content: Box<Region> }, +} + +/// 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<Option<Region>, 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<Option<Region>, 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<Option<Region>, 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<Option<Region>, 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<usize>> = 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<Option<Region>, 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<Option<Region>, 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<Option<Region>, 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<Vec<Poly>, 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<Point>, closed: bool) -> Option<Poly> { + 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<Region>) -> Option<Region> { + 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<Item = (f64, i32)>, 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<Vec<(usize, f64, i32)>>; + +/// 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<usize>, + parts: Vec<Layout>, + }, + /// The clipping path's shapes come first. + Clip { + clip_shapes: usize, + clip: Box<Layout>, + content: Box<Layout>, + }, +} + +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<Crossings, String> { + 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::<Vec<_>>(); + 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<Vec<Segment>, String> { + self.outline_counted(&mut 0) + } + + /// [`Region::outline`], adding the measure's work to `work`. + fn outline_counted(&self, work: &mut usize) -> Result<Vec<Segment>, 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<Vec<Segment>, 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<f64> { + 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<Vec<Segment>, 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<usize>> = 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<Value> = 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::<Vec<_>>()})).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<Rect> { + 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<Point> = 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<f64> = 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/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 981f85e6f5..988aa89572 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -176,6 +176,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams }, + BuiltinBrushLibrary { id: "dry-media", name: "Dry Media", make: dry_media }, ]; /// Built-in library `id`, made once. @@ -474,6 +475,170 @@ fn energy_beams() -> BrushLibrary { built("Energy Beams", Document::new(100.0, 100.0), brushes) } +/// The fixed-seed number source of the Dry Media grain: integer steps and plain arithmetic, so the +/// art is the same on every run and every platform. +struct Grain(u64); + +impl Grain { + /// The next number in 0..1. + fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + /// The next number in `a`..`b`. + fn range(&mut self, a: f64, b: f64) -> f64 { + a + (b - a) * self.next() + } +} + +/// `floor` at x = `x0` and `x1`, rising smoothly to 1 within `ramp` pt of each. +fn ends(x: f64, x0: f64, x1: f64, ramp: f64, floor: f64) -> f64 { + let t = ((x - x0).min(x1 - x) / ramp).clamp(0.0, 1.0); + floor + (1.0 - floor) * t * t * (3.0 - 2.0 * t) +} + +/// A closed band from x = `x0` to `x1` in `n` steps, at most `half` pt either side of y = 0 times +/// `taper(x)`; each edge point is pulled in by up to `rough` of that. +fn rough_band(g: &mut Grain, (x0, x1): (f64, f64), n: usize, half: f64, rough: f64, taper: impl Fn(f64) -> f64) -> PathData { + let xs: Vec<f64> = (0..=n).map(|i| x0 + (x1 - x0) * i as f64 / n as f64).collect(); + let mut pts: Vec<Point> = xs.iter().map(|&x| Point::new(x, -half * taper(x) * (1.0 - rough * g.next()))).collect(); + let bottom: Vec<Point> = xs.iter().rev().map(|&x| Point::new(x, half * taper(x) * (1.0 - rough * g.next()))).collect(); + pts.extend(bottom); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// A small four-cornered fleck around (`cx`, `cy`), at most `w` × `h` pt either side; one corner +/// in each direction, so its outline never crosses itself. +fn fleck(g: &mut Grain, cx: f64, cy: f64, w: f64, h: f64) -> PathData { + let mut side = |a: f64, b: f64| g.range(a, b); + let pts = [ + (cx - w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)), + (cx + w * side(-0.3, 0.3), cy - h * side(0.6, 1.0)), + (cx + w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)), + (cx + w * side(-0.3, 0.3), cy + h * side(0.6, 1.0)), + ]; + polygon(&pts) +} + +/// `body` with `holes` (inside it, apart from each other) cut out, even-odd, in gray `k`: what +/// lies under the stroke shows through the holes. +fn holed(body: PathData, holes: Vec<PathData>, k: f32) -> Node { + let children = std::iter::once(body).chain(holes).map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect(); + let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd }); + n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::gray(k))))], ..Default::default() }.into(); + n +} + +/// Path `p` filled with gray `k` (1 is black). +fn ink(p: PathData, k: f32) -> Node { + filled(p, Paint::solid(Color::gray(k))) +} + +/// Slivers `w` × `h` pt (ranges) in `count` slots along x = 6..94 inside a band `half` pt wide +/// times `taper`. Each stays within `reach` of the band's half width from y = 0, taken where the +/// band is narrowest over the sliver and the 3 pt either side of it, so with `reach` below the +/// band's roughness margin no sliver touches the band's edge or another sliver. +fn slivers(g: &mut Grain, count: usize, half: f64, reach: f64, (w, h): ((f64, f64), (f64, f64)), taper: impl Fn(f64) -> f64) -> Vec<PathData> { + let slot = 88.0 / count as f64; + (0..count) + .map(|i| { + let cx = 6.0 + slot * (i as f64 + 0.5); + let (w, h) = (g.range(w.0, w.1), g.range(h.0, h.1)); + let room = reach * half * taper(cx - w - 3.0).min(taper(cx + w + 3.0)); + let cy = g.range(-1.0, 1.0) * (room - h).max(0.0); + fleck(g, cx, cy, w, h) + }) + .collect() +} + +/// Dry Media: art brushes of grainy dry drawing marks, chalk, pencil, crayon and charcoal (original +/// vector grain from fixed seeds; every brush 100 pt long, black and grays that take the stroke +/// color as tints, gaps cut out so the paper shows). +pub(crate) fn dry_media() -> BrushLibrary { + // Chalk Grain, 100 × 8 pt: a soft band with narrow scratches and chips along its edges. + let chalk = { + let mut g = Grain(0x0c4a_1c0a); + let taper = |x: f64| ends(x, 0.0, 100.0, 10.0, 0.35); + let body = rough_band(&mut g, (0.0, 100.0), 36, 4.0, 0.25, taper); + let scratches = slivers(&mut g, 14, 4.0, 0.7, ((1.2, 2.4), (0.15, 0.3)), taper); + let mut parts = vec![holed(body, scratches, 0.8)]; + for i in 0..18 { + let cx = 3.0 + 94.0 * (i as f64 + g.range(0.2, 0.8)) / 18.0; + let side = if i % 2 == 0 { -1.0 } else { 1.0 }; + let cy = side * 4.0 * taper(cx) * g.range(0.7, 0.95); + let (w, h) = (g.range(0.3, 0.7), g.range(0.25, 0.5)); + let f = fleck(&mut g, cx, cy, w, h); + parts.push(ink(f, 1.0)); + } + group(parts) + }; + // Graphite Pencil, 100 × 2.5 pt: a solid tapered core with fine slanted hatching at its edges. + let pencil = { + let mut g = Grain(0x009e_4c11); + let core = rough_band(&mut g, (0.0, 100.0), 40, 0.8, 0.12, |x| ends(x, 0.0, 100.0, 18.0, 0.25)); + let mut parts = vec![ink(core, 1.0)]; + for i in 0..24 { + let x0 = 2.0 + 92.0 * (i as f64 + g.range(0.0, 0.4)) / 24.0; + let len = g.range(1.5, 3.0); + let t = g.range(0.12, 0.2); + let slant = g.range(0.1, 0.3); + let y0 = if i % 2 == 0 { -g.range(0.85, 1.05) } else { g.range(0.85, 1.25) - t - slant }; + parts.push(ink(polygon(&[(x0, y0 + slant), (x0 + len, y0), (x0 + len, y0 + t), (x0, y0 + slant + t)]), 0.6)); + } + group(parts) + }; + // Wax Crayon, 100 × 6 pt: a dense band with small gaps in its middle and short overlapping + // marks along both edges. + let crayon = { + let mut g = Grain(0x00c0_a7e5); + let taper = |x: f64| ends(x, 0.0, 100.0, 6.0, 0.5); + let body = rough_band(&mut g, (0.0, 100.0), 30, 2.6, 0.2, taper); + let gaps = slivers(&mut g, 16, 2.6, 0.34, ((0.6, 1.6), (0.15, 0.3)), taper); + let mut parts = vec![holed(body, gaps, 0.9)]; + for i in 0..36 { + let w = g.range(1.0, 2.2); + let cx = (1.0 + w).max(98.0 * (i as f64 + g.range(0.0, 1.0)) / 36.0).min(99.0 - w); + let side = if i % 2 == 0 { -1.0 } else { 1.0 }; + let h = g.range(0.4, 0.6); + // Clear of the gaps, which stay within 0.9 pt of the middle. + let inner = 0.9 + h; + let cy = side * g.range(inner, (2.4 * taper(cx)).max(inner)); + let f = fleck(&mut g, cx, cy, w, h); + parts.push(ink(f, 1.0)); + } + group(parts) + }; + // Charcoal Dust, 100 × 7 pt: a dark band broken into pieces, with flecks of dust either side. + let dust = { + let mut g = Grain(0xc4a2_c0a1); + let mut parts = vec![]; + let mut x0 = 0.0; + while x0 < 100.0 { + let x1 = (x0 + g.range(14.0, 24.0)).min(100.0); + let x1 = if 100.0 - x1 < 8.0 { 100.0 } else { x1 }; + let piece = rough_band(&mut g, (x0, x1), 10, 2.0, 0.3, |x| ends(x, 0.0, 100.0, 8.0, 0.4) * ends(x, x0, x1, 2.0, 0.3)); + parts.push(ink(piece, 1.0)); + x0 = x1 + g.range(1.5, 3.0); + } + for i in 0..30 { + let cx = 2.0 + 96.0 * (i as f64 + g.range(0.2, 0.8)) / 30.0; + let side = if g.next() < 0.5 { -1.0 } else { 1.0 }; + let s = g.range(0.15, 0.3); + let cy = side * g.range(2.3, 3.5 - s); + let f = fleck(&mut g, cx, cy, s, s); + parts.push(ink(f, if i % 3 == 0 { 0.7 } else { 1.0 })); + } + group(parts) + }; + let brushes = vec![ + art("Chalk Grain", chalk, Colorization::Tints), + art("Graphite Pencil", pencil, Colorization::Tints), + art("Wax Crayon", crayon, Colorization::Tints), + art("Charcoal Dust", dust, Colorization::Tints), + ]; + built("Dry Media", Document::new(100.0, 100.0), brushes) +} + // ---------- commands ---------- fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo { 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<CommandSpec> { "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/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index ff1fbe3591..4ed63e7ec1 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> { "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 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), 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, 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"), @@ -131,7 +132,7 @@ 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. +/// 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, @@ -139,6 +140,7 @@ fn sections( fonts: &BTreeSet<super::fonts::UsedFont>, images: &[ImageRow], repeats: &[(String, String)], + spot_objects: &[(String, String)], pattern_objects: &[(String, String)], gradient_objects: &[(String, String)], category: Option<&str>, @@ -183,7 +185,7 @@ fn sections( } "graphicStyles" => names("graphicStyleNames"), "brushes" => names("brushNames"), - "spotColors" => names("spotColors"), + "spotColors" => spot_objects.to_vec(), "patterns" => pattern_objects.to_vec(), "gradients" => gradient_objects.to_vec(), "symbols" => names("symbols"), @@ -273,15 +275,50 @@ fn gradient_label(p: &Paint) -> Option<String> { } } -/// 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<usize>)> { +/// 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<String> { + let mut out: Vec<String> = vec![]; + let mut add = |swatch: &Option<String>, 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<usize>)> { 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), @@ -297,43 +334,53 @@ fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::T Some((t, 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<String> + '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 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<usize>)>, - label: fn(&Paint) -> Option<String>, + label: &PaintNames<'_>, ) -> Vec<String> { 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<Vec<String>> = 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<String> = 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() || 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<String>) -> impl Fn(&Paint) -> Vec<String> { + move |p| label(p).into_iter().collect() +} + fn info(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "document.info"; let category = str_param(p, "category"); @@ -370,7 +417,13 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { }; 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<Value> = 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<String> = if selection_only { vec![] } else { vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect() }; @@ -446,17 +499,11 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { 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 +511,38 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { 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<String>), (&mut used_gradients, gradient_label)] - { - if let Some(name) = label(paint) - && !used.contains(&name) - { - used.push(name); + let labels: [(&mut Vec<String>, &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()); @@ -529,6 +595,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { "images": images, "symbols": symbols, "spotColors": spot, + "spotColorNames": spot, + "spotObjects": spot_objects, "swatches": d.swatches_iter().count(), "graphicStyles": d.graphic_styles.len(), "graphicStyleNames": styles, @@ -544,7 +612,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { }); // 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, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -997,6 +1065,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": "<Rectangle>", "spotColors": ["Ink"]}, + {"id": f, "name": "<Rectangle>", "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"], ["<Rectangle>", "Ink"], ["<Rectangle>", "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!([["<Rectangle>", "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<Rectangle>: Ink\n<Rectangle>: 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..b7affffa81 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> { "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 ), diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 1d39e2b4e4..5737235963 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> { "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 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 ), @@ -476,11 +476,14 @@ 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. + 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)) }; @@ -1234,8 +1237,64 @@ 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)) +} + +/// `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. +fn padded_on(g: &GradientPaint, bounds: Rect) -> (std::borrow::Cow<'_, GradientPaint>, Option<(f64, usize)>) { + use std::borrow::Cow; + if !g.gradient.is_spread() { + return (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) => { + let runs = Some((p.first, p.runs)); + (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)); + (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<f64> { + 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 +1463,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 +1485,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<Point> = [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/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs index 36ccd7cdb0..9a59d0f2f8 100644 --- a/crates/engine/src/cmd/fileinfo.rs +++ b/crates/engine/src/cmd/fileinfo.rs @@ -1,7 +1,8 @@ //! File → File Info (`file.info`): the document title and its descriptive metadata (author, //! description, keywords, rating, copyright), with the read-only created and modified dates. The -//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info -//! it gives ([`read_pdf`]). +//! title is the one the file the document was opened from gave itself ([`DocMetadata::title`]), +//! else the document's name. The SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or +//! `.ai` opens with the File Info it gives ([`read_pdf`]). use serde_json::{Map, Value, json}; use vectorcraft_doc::metadata::{MAX_RATING, iso8601}; @@ -22,7 +23,7 @@ const READ_ONLY: [&str; 6] = ["created", "modified", "colorMode", "units", "artb pub(crate) fn info_json(d: &Document) -> Value { let m = &d.metadata; json!({ - "title": d.title, + "title": d.info_title(), "author": m.author, "authorTitle": m.author_title, "description": m.description, @@ -48,14 +49,22 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> { _ => return Err(bad(C, "params must be an object")), }; let d = &s.doc()?.doc; - let (mut title, mut m) = (d.title.clone(), d.metadata.clone()); + let (mut title, mut m) = (d.info_title().to_string(), d.metadata.clone()); for (k, v) in o { apply(&mut title, &mut m, k, v)?; } let d = &s.doc()?.doc; - if title != d.title || m != d.metadata { + // A title File Info changes becomes the document's own, as it always has (the tab and Save As + // offer it where the document has no file), and replaces the one its file gave. + let retitled = title != d.info_title(); + if retitled { + m.title = None; + } + if retitled || m != d.metadata { s.edit("File Info", |d, _| { - d.title = title; + if retitled { + d.title = title; + } d.metadata = m; Ok(()) })?; @@ -64,8 +73,8 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> { } /// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each -/// field that `file.info` would take (a value it refuses is left out), and the created and -/// modified dates. +/// field that `file.info` would take (a value it refuses is left out), its title ([`imported_title`]) +/// as the File Info title, and the created and modified dates. pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) { let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain }); let fields = [ @@ -83,6 +92,7 @@ pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) { let _ = apply(&mut title, m, k, &v); } } + m.title = f.title.as_deref().and_then(imported_title); m.created = f.created.or(m.created); m.modified = f.modified.or(m.modified); } @@ -141,6 +151,18 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result<String> { Ok(s.to_string()) } +/// The File Info title `raw`, read from a file, makes: runs of white space (line breaks too) +/// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters +/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the +/// Untitled-N name New gives (what a document that never had a title writes to its files). +pub(crate) fn imported_title(raw: &str) -> Option<String> { + let words: Vec<String> = + raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::<String>()).filter(|w| !w.is_empty()).collect(); + let title: String = words.join(" ").chars().take(MAX_TEXT).collect(); + let title = title.trim_end(); + super::fileio::is_own_title(title).then(|| title.to_string()) +} + /// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring /// case), empty ones dropped. fn keywords(v: &Value) -> Result<Vec<String>> { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index d5a20f47ec..61eac58f37 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -92,9 +92,9 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, leave_out_unreadable_save_paths, - reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, - template_filters, templates_dialog_folder, templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, + leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, + save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; pub(crate) use screens::{ PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS, @@ -123,7 +123,10 @@ mod formats; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::list_dir; -pub use access::write_file_with; +pub use access::{write_file_with, write_save_file, write_save_file_with}; + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests_readonly; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::{Placed, copy_new, file_metadata}; pub(crate) use access::{ @@ -143,6 +146,7 @@ pub(crate) use export_names::{ artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return, }; use export_names::{distinct_names_by, write_encoded}; +pub(crate) use formats::is_own_title; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters, diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 7b20326106..c3668645e1 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -112,6 +112,31 @@ pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } +/// The save commands' writer: preserves a filesystem location refusal as a typed error. Other +/// writers keep their existing errors; an automation-root refusal is never reclassified. +pub fn write_save_file(path: &str, bytes: &[u8]) -> Result<()> { + write_save_file_with(path, &mut |w| w.write_all(bytes)) +} + +/// [`write_save_file`] for a streamed save. +#[cfg(not(target_arch = "wasm32"))] +pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + vectorcraft_doc::cancel::check()?; + may_write(path)?; + vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| save_write_error(path, e)) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn save_write_error(path: &str, error: std::io::Error) -> EngineError { + let reason = format!("{path}: {error}"); + if vectorcraft_format::read_only_location(&error) { EngineError::ReadOnlyLocation(reason) } else { EngineError::Other(reason) } +} + +#[cfg(target_arch = "wasm32")] +pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + write_file_with(path, fill) +} + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn create_dir(path: &str) -> Result<()> { may_write(path)?; diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0eeff7ab2e..0f91007515 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -592,7 +592,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?; // File Info as PNG text chunks. let bytes = if matches!(format, RasterFormat::Png | RasterFormat::Png8) { - vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title)) + vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(doc.info_title())) } else { bytes }; diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index 290f211ad4..fe962a36ad 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -370,7 +370,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> { let o = EpsOptions { region, origin, - title: doc.title.clone(), + title: doc.info_title().to_owned(), created, native: Some(native(&editing, if whole { None } else { board }, set.include_linked)), ..set.eps.clone() diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index aeaf962faf..d5137c8b1a 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -930,13 +930,20 @@ pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String { /// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep /// the stored title. pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool { - !doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + is_own_title(doc.info_title()) +} + +/// Is `title` one a person or a file gave, not blank and not the Untitled-N name New gives? +pub(crate) fn is_own_title(title: &str) -> bool { + !title.trim().is_empty() && !title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) } pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> { let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?; let mut d = doc.clone(); d.title = file_stem(path); + // A File Info title that is no title of its own (blank, or an Untitled-N name) would hide it. + d.metadata.title = None; Some(d) } diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index a197363b23..1fd3e10a9f 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -145,7 +145,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } } } - let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text }; + let o = vectorcraft_eps::LegacyAiOptions { title: doc.info_title().to_owned(), created, hidden, outline_text }; let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?; for w in placed.iter().chain(&out.warnings) { note(&mut enc.warnings, w); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index fde1ed273f..ec1d7e36ba 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -7,6 +7,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::Affine; use vectorcraft_render::effects::pixel::LoadedTexture; +use super::super::fileinfo::imported_title; use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param}; use super::pdfimport::LoadOptions; use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, jxlread, psdread, read_file, readable_format_for_name}; @@ -550,6 +551,15 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays); } + // The title the file gives itself (an SVG's `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` + // or Info `Title`, which the readers put in File Info) is separate from the document's name. + // Restored File Info is preserved exactly; newly imported SVG candidates and a restored + // document's legacy name fallback are cleaned as imported text. The name stays the file's (#3626). + if !restored && matches!(format.id, "svg" | "svgz") { + doc.metadata.title = doc.metadata.title.as_deref().and_then(imported_title); + } + let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title); + doc.metadata.title = doc.metadata.title.or_else(|| held.and_then(imported_title)); // Imports are named after the file; a native document keeps its own title (the tab shows the // file name once it has a path). if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() { @@ -678,6 +688,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St if template { doc.template = false; doc.title = s.next_untitled(); + doc.metadata.title = None; } // A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start // closed, its top-level layers as its groups (#1475). Ours carry their own. @@ -711,7 +722,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St st.import_losses = losses; st.links_from = read_from; let opened = &s.documents()[index]; - let (title, file_info_title) = (opened.title(), opened.doc.title.clone()); + let (title, file_info_title) = (opened.title(), opened.doc.info_title().to_string()); Ok(super::merge( json!({ "index": index, "title": title, "fileInfoTitle": file_info_title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json(), @@ -814,6 +825,9 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option<String> { /// A TIFF with more strips or tiles than the decoder can count /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are /// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. +/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is +/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG +/// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]). pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -852,7 +866,16 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> { let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?; // A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is // seen, so it places the way it was taken (#4284); the others keep their own bytes. - let turned = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff") && exif_orientation(bytes).is_some(); + let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten(); + let turned = orientation.is_some(); + // The resolution describes the stored pixels; a quarter turn swaps its axes. + let quarter = orientation.is_some_and(|o| o.to_exif() >= 5); + let shown_ppi = ppi.map(|(x, y)| if quarter { (y, x) } else { (x, y) }); + if let Some(orientation) = orientation.filter(|_| matches!(f.id, "jpg" | "tiff")) + && let Some((tiff, size)) = vectorcraft_doc::cmyk::turned_cmyk(bytes, orientation, shown_ppi) + { + return stored(tiff, "image/tiff", size, shown_ppi); + } let kept = match f.id { _ if turned => None, "png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()), @@ -868,36 +891,39 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> { vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) }; let mut img = match (f.id == "tiff").then(extra_samples).flatten() { - Some(img) => img, + Some(img) => image::DynamicImage::ImageRgba8(img), None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() { - Some(read) => read.map_err(err)?, + Some(read) => image::DynamicImage::ImageRgba8(read.map_err(err)?), None => { use image::ImageDecoder as _; - let mut decoder = reader.into_decoder().map_err(err)?; + let decoder = reader.into_decoder().map_err(err)?; // The allowance counts the decoded samples and the RGBA pixels they become (#1717). let (w, h) = decoder.dimensions(); if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC { return Err(err(format!("an image of {w} × {h} pixels is too large to draw"))); } - let orientation = decoder.orientation().unwrap_or(image::metadata::Orientation::NoTransforms); - let mut img = image::DynamicImage::from_decoder(decoder).map_err(err)?; - img.apply_orientation(orientation); - img.to_rgba8() + image::DynamicImage::from_decoder(decoder).map_err(err)? } }, }; + img.apply_orientation(orientation.unwrap_or(image::metadata::Orientation::NoTransforms)); + let mut img = img.into_rgba8(); if f.id == "tga" { super::tgaread::fix_alpha(bytes, &mut img); } - as_png(img, None, ppi) + as_png(img, None, shown_ppi) } /// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when -/// it leaves them as stored, or the image has none. +/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with +/// extra samples) has its Orientation tag read from its first image +/// ([`vectorcraft_doc::cmyk::tiff_orientation`]). pub(crate) fn exif_orientation(bytes: &[u8]) -> Option<image::metadata::Orientation> { use image::ImageDecoder as _; - let mut decoder = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; - decoder.orientation().ok().filter(|o| *o != image::metadata::Orientation::NoTransforms) + use image::metadata::Orientation; + let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok(); + let tagged = || vectorcraft_doc::cmyk::tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?)); + decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms) } /// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index ba05ec19e2..58677cd5d4 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -157,6 +157,11 @@ fn documents_folder() -> Option<std::path::PathBuf> { Some(home.join("Documents")) } +/// A Save As after a refused Save starts in Documents, else home, never the refused folder. +pub fn documents_dialog_folder() -> Option<String> { + documents_folder().filter(|p| p.is_dir()).map(|p| p.to_string_lossy().into_owned()).or_else(home_folder) +} + /// `XDG_DOCUMENTS_DIR` in a `user-dirs.dirs` file's `text`, `$HOME` being `home`. None when it isn't /// set, is relative, or is the home folder itself (the spec's way of turning it off). fn xdg_documents(text: &str, home: &std::path::Path) -> Option<std::path::PathBuf> { @@ -731,7 +736,7 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res // With the artboards' background colors, as the pages draw them. let art = super::export::with_artboard_fills(art, ¶ms, 0..art.artboards.len()).map_err(|e| bad(cmd, e))?; let o = vectorcraft_eps::AiEditingOptions { - title: doc.title.clone(), + title: doc.info_title().to_owned(), created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time), }; let (data, lost) = vectorcraft_eps::ai_private_data(&art, &o).map_err(|e| bad(cmd, e))?; @@ -1043,7 +1048,7 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value> 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_file_with)?; + let mut out = job.write_streamed(&mut super::write_save_file_with)?; job.complete(s, uid); leave_out_unreadable_save_paths(&mut out, str_param(p, "path")); Ok(out) @@ -1170,7 +1175,7 @@ pub(super) fn specs() -> Vec<CommandSpec> { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index b48aef68c3..ca28407356 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -653,6 +653,11 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> { 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::shown_path(folder).into_owned())); + } if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) { leave_out_unreadable_folder(&mut r, folder); } @@ -663,10 +668,14 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> { /// 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); + leave_out_folder_named(e, folder, &super::access::shown_path(folder)) +} + +/// [`leave_out_folder_from_error`] with `folder` named `name`. +fn leave_out_folder_named(e: EngineError, folder: &str, name: &str) -> EngineError { 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) }, + 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, } } @@ -699,9 +708,14 @@ fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String /// `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}/"); @@ -1074,7 +1088,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> { // 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 +1109,27 @@ pub struct ScreenExport { rows: Vec<Row>, naming: Naming, items: Vec<Item>, + /// 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<String>)> { + 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 +1155,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<Value> { + pub fn write(mut self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> { 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| leave_out_folder_named(e, &folder, &name))?; + leave_out_folder(&mut r, &folder); + Ok(r) + } + None => r, + } } } diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index c90e077eb4..5ea57858cc 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -907,6 +907,254 @@ fn a_photo_places_the_way_its_exif_orientation_says() { assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top"); } +/// Where the first stored pixel shows once EXIF (or TIFF) `orientation` turns the image, as +/// (right, bottom): orientations 1 and 5 keep it at the top left, 2 and 6 put it at the top right, +/// 3 and 7 at the bottom right, 4 and 8 at the bottom left. Values outside 1 to 8 leave it. +fn first_pixel_at(orientation: u16) -> (bool, bool) { + match orientation { + 2 | 6 => (true, false), + 3 | 7 => (true, true), + 4 | 8 => (false, true), + _ => (false, false), + } +} + +/// A little-endian EXIF block (a TIFF header and directory, as a PNG `eXIf` chunk or a WebP +/// `EXIF` chunk holds it) with an Orientation of `orientation` and an XResolution and YResolution +/// of `x` and `y` pixels per inch. +fn exif_block(orientation: u16, (x, y): (u32, u32)) -> Vec<u8> { + let entry = |tag: u16, kind: u16, value: u32| [&tag.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &value.to_le_bytes()].concat(); + // Orientation, XResolution, YResolution, ResolutionUnit inches; the two rationals follow. + [ + b"II".as_slice(), + &[0x2a, 0, 8, 0, 0, 0], + &4u16.to_le_bytes(), + &entry(0x0112, 3, u32::from(orientation)), + &entry(0x011a, 5, 62), + &entry(0x011b, 5, 70), + &entry(0x0128, 3, 2), + &[0, 0, 0, 0], + &[x.to_le_bytes(), 1u32.to_le_bytes(), y.to_le_bytes(), 1u32.to_le_bytes()].concat(), + ] + .concat() +} + +/// A photo turned a quarter by its EXIF orientation keeps its physical size: the resolution's +/// axes swap with the pixels', in what Place reports and in the stored image. Every orientation +/// puts the first stored pixel in its corner once, and the stored image, read again, is not turned +/// a second time. Values outside 1 to 8 leave the photo as stored. +#[test] +fn a_quarter_turn_swaps_the_resolution_axes() { + // 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) red, the rest white. + let rgb = image::RgbImage::from_fn(48, 32, |x, y| if x < 16 && y < 16 { image::Rgb([220, 0, 0]) } else { image::Rgb([255, 255, 255]) }); + let mut jpg = Vec::new(); + image::DynamicImage::ImageRgb8(rgb).write_to(&mut Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap(); + for orientation in [0u16, 1, 2, 3, 4, 5, 6, 7, 8, 9] { + let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat(); + // Without a JFIF density the resolution comes from the EXIF block. + let mut turned = jpg[..2].to_vec(); + turned.extend([0xff, 0xe1]); + turned.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + turned.extend(&exif); + turned.extend(&jpg[2..]); + let what = format!("orientation {orientation}"); + assert_eq!(super::ppi::resolution(&turned), Some((300.0, 150.0)), "{what}: as stored"); + let quarter = (5..=8).contains(&orientation); + let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) }; + let img = raster_image(&turned).unwrap(); + assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_rgb8(); + let red = |x: u32, y: u32| px.get_pixel(x, y)[1] < 80; + let (w, h) = size; + let corners = [(false, false), (true, false), (true, true), (false, true)]; + let reds: Vec<_> = + corners.into_iter().filter(|&(right, bottom)| red(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 })).collect(); + assert_eq!(reds, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner"); + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.ppi), (size, Some(ppi)), "{what}: read again, it isn't turned again"); + } +} + +/// `tiff` (little-endian) with an Orientation tag (274) of `value`, a SHORT or, when `long`, a +/// LONG, and, when `y_ppi` is given, a YResolution (283, which `tiff` must have) of that many +/// pixels per inch: its first directory copied to the end of the file with the tag added in order, +/// and the header pointing there, so that every other offset in the file still holds. +fn tiff_oriented(tiff: &[u8], value: u16, long: bool, y_ppi: Option<u32>) -> Vec<u8> { + let at = usize::try_from(u32::from_le_bytes(tiff[4..8].try_into().unwrap())).unwrap(); + let n = usize::from(u16::from_le_bytes([tiff[at], tiff[at + 1]])); + let mut entries: Vec<Vec<u8>> = tiff[at + 2..at + 2 + 12 * n].chunks(12).map(<[u8]>::to_vec).collect(); + let i = entries.iter().position(|e| u16::from_le_bytes([e[0], e[1]]) > 274).unwrap_or(n); + let (kind, v) = if long { (4u16, u32::from(value).to_le_bytes()) } else { (3, [value.to_le_bytes(), [0, 0]].concat().try_into().unwrap()) }; + entries.insert(i, [&274u16.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &v].concat()); + let mut out = tiff.to_vec(); + if out.len() % 2 == 1 { + out.push(0); + } + if let Some(y) = y_ppi { + let rational = u32::try_from(out.len()).unwrap(); + out.extend([y.to_le_bytes(), 1u32.to_le_bytes()].concat()); + let e = entries.iter_mut().find(|e| u16::from_le_bytes([e[0], e[1]]) == 283).unwrap(); + e[8..12].copy_from_slice(&rational.to_le_bytes()); + } + let ifd = u32::try_from(out.len()).unwrap(); + out[4..8].copy_from_slice(&ifd.to_le_bytes()); + out.extend(u16::try_from(n + 1).unwrap().to_le_bytes()); + out.extend(entries.concat()); + out.extend([0; 4]); + out +} + +/// TIFFs the image decoder can't read, grayscale with an alpha channel and 1-bit ones compressed +/// with CCITT Group 3, turn and flip as their Orientation tag says, as the others do (#4284), +/// whether the tag is a SHORT or a LONG. A quarter turn swaps the resolution's axes. +#[test] +fn tiffs_read_without_the_image_decoder_follow_their_orientation() { + // 3 × 2 at 150 × 300 ppi, the first pixel black and the rest white, opaque. + let gray = hand_tiff(1, 8, &[2], false, &[vec![0, 255, 255, 255, 255, 255], vec![255; 6]]); + let rows: Vec<Vec<bool>> = (0..2).map(|y| (0..3).map(|x| x == 0 && y == 0).collect()).collect(); + let g = vectorcraft_testkit::tiff::Group3 { + rows: &rows, + compression: 2, + t4_options: 0, + photometric: 0, + fill_order: 1, + rows_per_strip: 2, + tile: None, + rtc: false, + }; + let fax = vectorcraft_testkit::tiff::group3(&g); + // The Group 3 file declares no resolution. + for (kind, file, y_ppi) in [("Group 3", fax, None), ("gray with alpha", gray, Some(300))] { + assert!(image::load_from_memory(&file).is_err(), "{kind}: the image decoder reads it now"); + for (orientation, long) in (1..=8).flat_map(|o| [(o, false), (o, true)]) { + let what = format!("{kind}, orientation {orientation}, LONG {long}"); + let img = raster_image(&tiff_oriented(&file, orientation, long, y_ppi)).unwrap_or_else(|e| panic!("{what}: {e}")); + let quarter = orientation >= 5; + let size = if quarter { (2, 3) } else { (3, 2) }; + let ppi = y_ppi.map(|y| if quarter { (f64::from(y), 150.0) } else { (150.0, f64::from(y)) }); + assert_eq!(((img.width, img.height), img.ppi), (size, ppi), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), ppi, "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8(); + let (right, bottom) = first_pixel_at(orientation); + let (x, y) = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect(); + assert_eq!(black, [(x, y)], "{what}: the first stored pixel's corner"); + } + } +} + +/// A CMYK TIFF that its Orientation tag turns or flips keeps its inks, turned, whether it stores +/// them together or one plane per ink, at 8 or 16 bits; a quarter turn swaps the resolution's axes. +/// Read again, the stored image is not turned a second time. +#[test] +fn cmyk_tiffs_turned_by_their_orientation_keep_their_inks() { + // 3 × 2 at 150 × 300 ppi: the first pixel full cyan, the others 30% black. + let first: [u8; 4] = [255, 0, 0, 0]; + let other: [u8; 4] = [0, 0, 0, 77]; + let channel = |i: usize| -> Vec<u16> { (0..6).map(|p| u16::from(if p == 0 { first[i] } else { other[i] })).collect() }; + for (bits, planar) in [(8, false), (8, true), (16, false), (16, true)] { + let channels: Vec<Vec<u16>> = (0..4).map(|i| channel(i).iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect(); + let file = hand_tiff(5, bits, &[], planar, &channels); + for orientation in 1..=8 { + let what = format!("{bits} bits, planar {planar}, orientation {orientation}"); + let img = raster_image(&tiff_oriented(&file, orientation, false, Some(300))).unwrap_or_else(|e| panic!("{what}: {e}")); + let quarter = orientation >= 5; + let size = if quarter { (2, 3) } else { (3, 2) }; + let ppi = if quarter { (300.0, 150.0) } else { (150.0, 300.0) }; + assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), "image/tiff"), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost")); + assert_eq!((inks.width, inks.height), size, "{what}: the inks' size"); + let (right, bottom) = first_pixel_at(orientation); + let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + for (i, px) in inks.data.chunks(4).enumerate() { + let (x, y) = (u32::try_from(i).unwrap() % size.0, u32::try_from(i).unwrap() / size.0); + assert_eq!(px, if (x, y) == at { first } else { other }, "{what}: the inks of pixel ({x}, {y})"); + } + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again"); + } + } +} + +/// A CMYK JPEG that its EXIF orientation turns or flips keeps its inks, turned, stored as a CMYK +/// TIFF; a quarter turn swaps the resolution's axes. Orientation 1 keeps the JPEG as it is, and +/// the stored image, read again, is not turned a second time. +#[test] +fn cmyk_jpegs_turned_by_their_orientation_keep_their_inks() { + use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions}; + // 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) full cyan, the rest 30% black. + let inks: Vec<u8> = + (0..32u32).flat_map(|y| (0..48u32).flat_map(move |x| if x < 16 && y < 16 { [255, 0, 0, 0] } else { [0, 0, 0, 77] })).collect(); + let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..Default::default() }; + let jpg = jpeg::encode(&inks, 48, 32, 100, None, &o).unwrap(); + for orientation in 1..=8u16 { + let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat(); + let mut file = jpg[..2].to_vec(); + file.extend([0xff, 0xe1]); + file.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + file.extend(&exif); + file.extend(&jpg[2..]); + let what = format!("orientation {orientation}"); + let quarter = orientation >= 5; + let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) }; + let mime = if orientation == 1 { "image/jpeg" } else { "image/tiff" }; + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), mime), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost")); + assert_eq!((inks.width, inks.height), size, "{what}: the inks' size"); + let (w, h) = size; + let at = |x: u32, y: u32| inks.data[usize::try_from((y * w + x) * 4).unwrap()..][..4].to_vec(); + let cyan = |p: &[u8]| p[0] > 200 && p[3] < 40; + let corners = [(false, false), (true, false), (true, true), (false, true)]; + let cyans: Vec<_> = + corners.into_iter().filter(|&(right, bottom)| cyan(&at(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 }))).collect(); + assert_eq!(cyans, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner"); + let black = at(w / 2, h / 2); + assert!(black[0] < 40 && (60..=95).contains(&black[3]), "{what}: the other inks, {black:?}"); + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again"); + } +} + +/// A PNG (its `eXIf` chunk and `pHYs` resolution), a WebP (its `EXIF` chunk) and an RGB TIFF read +/// by the image decoder, turned a quarter by their orientation, swap their resolution's axes; a +/// half turn keeps them. +#[test] +fn png_webp_and_rgb_tiff_turned_a_quarter_swap_their_resolution() { + use image::ImageEncoder as _; + let rgb = image::RgbImage::from_fn(3, 2, |x, y| image::Rgb(if x == 0 && y == 0 { [0, 0, 0] } else { [255, 255, 255] })); + for orientation in [3u16, 6, 8] { + let mut png = Vec::new(); + let mut enc = image::codecs::png::PngEncoder::new(&mut png); + enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap(); + enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let png = super::ppi::with_png_resolution(&png, (150.0, 300.0)); + let mut webp = Vec::new(); + let mut enc = image::codecs::webp::WebPEncoder::new_lossless(&mut webp); + enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap(); + enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let channel = |c: usize| -> Vec<u16> { rgb.pixels().map(|p| u16::from(p[c])).collect() }; + let tiff = tiff_oriented(&hand_tiff(2, 8, &[], false, &[channel(0), channel(1), channel(2)]), orientation, false, Some(300)); + for (kind, file) in [("PNG", png), ("WebP", webp), ("TIFF", tiff)] { + let what = format!("{kind}, orientation {orientation}"); + assert_eq!(super::ppi::resolution(&file), Some((150.0, 300.0)), "{what}: as stored"); + let quarter = orientation >= 5; + let (size, ppi) = if quarter { ((2, 3), (300.0, 150.0)) } else { ((3, 2), (150.0, 300.0)) }; + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8(); + let (right, bottom) = first_pixel_at(orientation); + let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect(); + assert_eq!(black, [at], "{what}: the first stored pixel's corner"); + } + } +} + /// With `importer` named, a file up to the importer's limit is read whole and a larger one is an /// error, whatever its content. #[test] diff --git a/crates/engine/src/cmd/fileio/tests_readonly.rs b/crates/engine/src/cmd/fileio/tests_readonly.rs new file mode 100644 index 0000000000..badf82a8cf --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_readonly.rs @@ -0,0 +1,52 @@ +//! Save errors retain their type without changing the other filesystem writers (#6146). + +use std::io::{self, ErrorKind}; + +use serde_json::json; + +use super::{SaveMode, access, save_plan, save_with, write_file_with, write_save_file_with}; +use crate::{EngineError, READ_ONLY_LOCATION, Session}; + +#[test] +fn readonly_error_is_typed_and_other_writers_keep_their_error() { + let dir = vectorcraft_testkit::temp_dir("save-refusal"); + let path = dir.join("Poster.vectorcraft").to_string_lossy().into_owned(); + for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem, ErrorKind::StorageFull, ErrorKind::NotFound, ErrorKind::Other] { + let typed = write_save_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err(); + let ordinary = write_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err(); + let reason = format!("{path}: injected"); + assert!(matches!(&ordinary, EngineError::Other(_))); + assert_eq!(ordinary.to_string(), reason); + if matches!(kind, ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) { + assert!(matches!(&typed, EngineError::ReadOnlyLocation(_))); + assert_eq!(typed.to_string(), format!("{READ_ONLY_LOCATION}: {reason}")); + } else { + assert!(matches!(&typed, EngineError::Other(_))); + assert_eq!(typed.to_string(), reason); + } + assert!(!std::path::Path::new(&path).exists()); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn readonly_failed_save_keeps_document_and_options() { + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 40, "height": 40})).unwrap(); + session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + let st = session.doc_mut().unwrap(); + st.path = Some("Poster.svg".into()); + st.format = "svg"; + st.save_options = json!({"decimals": 3}).as_object().unwrap().clone(); + let before = serde_json::to_value(&*st.doc).unwrap(); + let options = st.save_options.clone(); + let plan = save_plan(&session, SaveMode::Save, &json!({"modified": null})).unwrap(); + let e = save_with(&mut session, plan, |path, _| Err(access::save_write_error(path, io::Error::from(ErrorKind::PermissionDenied)))).unwrap_err(); + assert!(matches!(e, EngineError::ReadOnlyLocation(_))); + let st = session.doc().unwrap(); + assert_eq!(st.path.as_deref(), Some("Poster.svg")); + assert_eq!(st.format, "svg"); + assert_eq!(st.save_options, options); + assert!(st.is_dirty()); + assert_eq!(serde_json::to_value(&*st.doc).unwrap(), before); +} diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 86ee164998..1b3fe3cfec 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -730,11 +730,86 @@ fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_ let settings = json!({"formats": [{"format": "png"}], "folder": parent}); s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap(); let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string(); - let root = inside.display().to_string(); + let root = crate::cmd::findfiles::plain(inside.canonicalize().unwrap()).display().to_string(); let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned(); 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<std::sync::Arc<AutomationRoots>>, 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 ec3ce57e3a..bf51ebdac7 100644 --- a/crates/engine/src/cmd/findfiles/tests.rs +++ b/crates/engine/src/cmd/findfiles/tests.rs @@ -183,6 +183,16 @@ fn a_picked_folder_inside_other_apps_folders_is_refused() { assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}"); let p = search(&root, "missing", Limits::default(), 1, &[]); assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}"); + // Each failure has the same reason without the folder's path. + let briefs = [ + ("missing", "the folder can't be read"), + ("file.ttf", "the path isn't a folder"), + ("refused", "VectorCraft doesn't search other apps' or the system's folders"), + ("Users/Public/x", "VectorCraft doesn't search the Shared or Public folder in Users"), + ]; + for (picked, why) in briefs { + assert_eq!(search(&root, picked, Limits::default(), 1, &[]).failure(), Some(why), "{picked}"); + } } #[test] @@ -362,7 +372,7 @@ fn a_picked_folder_that_cant_be_listed_fails_the_search() { let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); if enforced { - assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}"); + assert!(matches!(&p.end, Some(End::Failed(e, "the folder can't be listed")) if e.contains("locked")), "{p:?}"); } else { assert_eq!(found(&root, &p), ["locked/y.ttf"]); } diff --git a/crates/engine/src/cmd/findfiles/walk.rs b/crates/engine/src/cmd/findfiles/walk.rs index 8d35515717..ae05a04838 100644 --- a/crates/engine/src/cmd/findfiles/walk.rs +++ b/crates/engine/src/cmd/findfiles/walk.rs @@ -23,6 +23,13 @@ const CHECK_EVERY: u64 = 1024; /// How a search ends when every walker thread stopped on a bug. const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)"; +/// Why a search couldn't start, without the folder's path ([`Progress::failure`]). +const UNREADABLE: &str = "the folder can't be read"; +const NOT_A_FOLDER: &str = "the path isn't a folder"; +const SHARED: &str = "VectorCraft doesn't search the Shared or Public folder in Users"; +const APPS: &str = "VectorCraft doesn't search other apps' or the system's folders"; +const UNLISTED: &str = "the folder can't be listed"; + /// What a search looks for. pub trait Visitor: Send + Sync { /// Whether the regular file named `name` is to be read: asked of the files a folder lists until @@ -77,8 +84,9 @@ pub enum End { /// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item /// keeps at most [`Limits::per_item`] files, without ending the search. Limit, - /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. - Failed(String), + /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. Why, + /// and the same without the folder's path. + Failed(String, &'static str), } /// Where a search stands ([`Search::progress`]). @@ -114,7 +122,7 @@ impl Progress { None => "searching", Some(End::Done | End::Found) => "done", Some(End::Stopped | End::Time | End::Limit) => "stopped", - Some(End::Failed(_)) => "failed", + Some(End::Failed(..)) => "failed", } } @@ -131,10 +139,38 @@ impl Progress { /// Why a failed search failed. pub fn error(&self) -> Option<&str> { match &self.end { - Some(End::Failed(e)) => Some(e), + Some(End::Failed(e, _)) => Some(e), _ => None, } } + + /// Why a failed search failed, without the folder's path. + pub fn failure(&self) -> Option<&'static str> { + match &self.end { + Some(End::Failed(_, why)) => Some(why), + _ => None, + } + } + + /// Keep what the caller asking now may read under its read roots ([`crate::file_access::current`]), + /// for a reply to it: the files found inside them. Returns the folder searched (`given` until a + /// walker checked it) and why the search failed, when that folder lies inside them too; + /// otherwise no folder and why without its path ([`Self::failure`]). Without read roots in + /// force, everything is kept. + pub fn keep_readable(&mut self, given: &str) -> (Option<String>, Option<String>) { + let folder = if self.folder.as_os_str().is_empty() { given.to_string() } else { self.folder.to_string_lossy().into_owned() }; + if crate::file_access::current().is_none() { + return (Some(folder), self.error().map(str::to_string)); + } + // A path that isn't valid text is never shown: its lossy form names another file. + let readable = |p: &Path| p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok()); + self.hits.retain(|(path, _)| readable(path)); + if readable(Path::new(given)) && (self.folder.as_os_str().is_empty() || readable(&self.folder)) { + (Some(folder), self.error().map(str::to_string)) + } else { + (None, self.failure().map(str::to_string)) + } + } } /// Walker threads shared out among searches: all of them together run at most `cap` threads. @@ -306,7 +342,7 @@ pub(crate) fn start_in( return Err("the search couldn't start: try again in a moment".into()); } if w.alive == 0 && w.end.is_none() { - shared.finish(&mut w, End::Failed(INTERNAL.into())); + shared.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)); } } Ok(Search { shared }) @@ -401,13 +437,13 @@ impl Shared { fn prepare(&self, root: &Path) { let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system)); let path = root.display(); - let checked = match std::fs::canonicalize(root) { - Err(e) => Err(format!("{path}: {e}")), - Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")), + let (checked, brief) = match std::fs::canonicalize(root) { + Err(e) => (Err(format!("{path}: {e}")), UNREADABLE), + Ok(folder) if !folder.is_dir() => (Err(format!("{path} is not a folder")), NOT_A_FOLDER), Ok(folder) => match rules.refusal(&folder) { - None => Ok(folder), - Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), - Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), + None => (Ok(folder), INTERNAL), + Some(Refusal::Shared) => (Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), SHARED), + Some(Refusal::Apps) => (Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), APPS), }, }; let mut w = self.lock(); @@ -416,7 +452,7 @@ impl Shared { w.folder = folder.clone(); w.stack.push((folder, 0)); } - Err(e) => self.finish(&mut w, End::Failed(e)), + Err(e) => self.finish(&mut w, End::Failed(e, brief)), } } @@ -430,7 +466,7 @@ impl Shared { let mut w = self.lock(); // The picked folder itself: the search can't run, and says why. if depth == 0 { - self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()))); + self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()), UNLISTED)); } else { w.unreadable += 1; w.folders += 1; @@ -610,7 +646,7 @@ fn walker(s: &Shared) { if done.is_err() { let mut w = s.lock(); match job { - Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())), + Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)), Job::List(..) => w.unreadable += 1, } } @@ -640,7 +676,7 @@ impl Drop for Alive { let mut w = self.0.lock(); w.alive = w.alive.saturating_sub(1); if w.alive == 0 && w.end.is_none() { - self.0.finish(&mut w, End::Failed(INTERNAL.into())); + self.0.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)); } drop(w); self.0.pool.release(1); diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index 309347985c..0ccf1ab94a 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> { "Find Font Files", [], None, - "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links). With automation read roots, a reply leaves out the files found outside the caller's roots, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", always, find ), @@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> { "Add Font Files", [], None, - "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", + "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install. With automation read roots, folder is null when the Fonts folder is outside them, and a to or file path outside them is given as its file name; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", always, add ), @@ -275,7 +275,9 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> { /// Where the session's last search stands (`text.findFontFiles` without `folder`). fn state(s: &Session) -> Value { let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) }; - let p = f.search.progress(); + let mut p = f.search.progress(); + // Only what the caller asking now may read: a search another caller started can hold more. + let (folder, error) = p.keep_readable(&f.folder); let mut files: Vec<Vec<String>> = vec![vec![]; f.fonts.len()]; for (path, items) in &p.hits { for i in items { @@ -293,16 +295,18 @@ fn state(s: &Session) -> Value { json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files }) }) .collect(); - let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() }; let mut out = json!({ - "id": f.id, "state": p.state(), "folder": folder, "fonts": fonts, + "id": f.id, "state": p.state(), "fonts": fonts, "searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read }, "skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0, }); + if let Some(folder) = folder { + out["folder"] = json!(folder); + } if let Some(why) = p.stopped() { out["stopped"] = json!(why); } - if let Some(e) = p.error() { + if let Some(e) = error { out["error"] = json!(e); } out @@ -348,12 +352,16 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> { // VectorCraft's own Fonts folder: no automation root applies to it. crate::file_access::unconfined(|| super::fileio::create_dir(&dir.to_string_lossy()))?; let report = copy_into(&files, dir); - let pairs = |v: &[(PathBuf, PathBuf)]| { - v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::<Vec<_>>() + // The caller sees the Fonts folder and the copies in it only where its read roots reach. + let readable = |p: &Path| crate::file_access::current().is_none() || p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok()); + let shown = |p: &Path| -> String { + let shown = if readable(p) { Some(p.as_os_str()) } else { p.file_name() }; + shown.map(|s| s.to_string_lossy().into_owned()).unwrap_or_default() }; + let pairs = |v: &[(PathBuf, PathBuf)]| v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": shown(to) })).collect::<Vec<_>>(); let mut out = json!({ - "folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept), - "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::<Vec<_>>(), + "folder": readable(dir).then(|| dir.to_string_lossy()), "copied": pairs(&report.copied), "kept": pairs(&report.kept), + "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": shown(file), "reason": why })).collect::<Vec<_>>(), }); if !report.copied.is_empty() || !report.kept.is_empty() { // The fonts are cataloged and type set in them redraws, as Refresh Font List does it. 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<Option<(u64, Options, Outlines)>>; + +/// 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<CommandSpec> { "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<Option<GradientMethod>> { } } +/// The `spread` param of `p`, if given: what the gradient paints past the ends of its stops. +fn spread_param(p: &Value) -> Parsed<Option<GradientSpread>> { + 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<Option<u32>> { + 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<GradientPaint> { 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<Value> { } _ => 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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<Item = GradientPresetEntry> { }) } -/// 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<Value> { 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..43d9a8e05f 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> { "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 ), @@ -926,6 +926,9 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> { 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())) { @@ -941,6 +944,10 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> { 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), }; + // 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/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index 5b5a6aa48a..f2ce9d4d12 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> { "Find Linked Files", [], None, - "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits; none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits. With automation read roots, a reply leaves out the files found outside the caller's roots, a link's path outside them, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path?, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", always, find )] @@ -52,6 +52,8 @@ pub(crate) struct LinkSearch { pub(crate) id: u64, /// The folder as given. pub(crate) folder: String, + /// The path of the document the links were taken from, which a relative link path starts from. + pub(crate) doc: Option<String>, pub(crate) links: Vec<Sought>, /// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]). pub(crate) sizes: Arc<Mutex<HashMap<PathBuf, u64>>>, @@ -163,6 +165,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> { if links.is_empty() { return Err(bad(C, if ids.is_some() { "these objects show no linked files" } else { "no linked files are missing" })); } + let doc = super::links::LinkBase::of(st).doc.map(str::to_string); // A new search stops the last one. s.link_search = None; let threads = s.search_threads.unwrap_or_else(findfiles::threads); @@ -171,7 +174,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> { let visitor = Arc::new(LinkFiles::new(&links, sizes.clone())); let search = findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, 2 * links.len(), visitor).map_err(EngineError::Other)?; - s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, links, sizes, search }); + s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, doc, links, sizes, search }); Ok(state(s)) } @@ -209,7 +212,9 @@ fn shared_folders(saved: &str, found: &Path) -> usize { /// Where the session's last search stands (`links.findFiles` without `folder`). fn state(s: &Session) -> Value { let Some(l) = &s.link_search else { return json!({ "state": "idle", "links": [] }) }; - let p = l.search.progress(); + let mut p = l.search.progress(); + // Only what the caller asking now may read: a search another caller started can hold more. + let (folder, error) = p.keep_readable(&l.folder); let n = l.links.len(); let sizes = lock(&l.sizes).clone(); // Per linked file: the files found, each with whether it is the same file. @@ -267,19 +272,23 @@ fn state(s: &Session) -> Value { f }) .collect(); - json!({ "name": link.name, "path": link.path, "ids": link.ids, "files": files }) + let mut row = json!({ "name": link.name, "ids": link.ids, "files": files }); + super::links::note_path(&mut row, "path", &link.path, l.doc.as_deref()); + row }) .collect(); - let folder = if p.folder.as_os_str().is_empty() { l.folder.clone() } else { p.folder.to_string_lossy().into_owned() }; let mut out = json!({ - "id": l.id, "state": p.state(), "folder": folder, "links": links, + "id": l.id, "state": p.state(), "links": links, "searched": { "folders": p.folders, "files": p.files, "read": p.read }, "skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0, }); + if let Some(folder) = folder { + out["folder"] = json!(folder); + } if let Some(why) = p.stopped() { out["stopped"] = json!(why); } - if let Some(e) = p.error() { + if let Some(e) = error { out["error"] = json!(e); } out diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..e0bacc3859 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -436,7 +436,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a continue; }; let preview = d.images.get(&key).map(|b| b.bytes.clone()); - // A file read as it is keeps its own type; one turned upright (EXIF orientation) is the PNG it became. + // A file read as it is keeps its own type; one turned upright by its orientation is the PNG, or CMYK TIFF, it became. let mime = match fileio::format_for_name(g.link.name()) { Some(f) if img.blob.bytes.as_slice() == bytes.as_slice() => f.mime.to_string(), _ => img.blob.mime.clone(), @@ -898,12 +898,12 @@ fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec< /// May a reply to the caller asking now carry the link path `path` (of the document saved at /// `doc_path`)? Always without read roots in force; with them, only when the file it names (a /// relative path taken from the document's folder) lies inside them. -fn path_shown(path: &str, doc_path: Option<&str>) -> bool { +pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool { crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok()) } /// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`. -fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) { +pub(crate) fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) { if path_shown(path, doc_path) { row[key] = json!(path); } else if let Some(o) = row.as_object_mut() { diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 5480f7ae58..1013c606c5 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -145,7 +145,7 @@ pub fn specs() -> Vec<CommandSpec> { "Make with Warp…", ["Object", "Envelope Distort"], Some("Cmd+Alt+Shift+W"), - "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}", + "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v, version?: 0|1 (1: each style bends the bounds as one Bézier patch; 0: VectorCraft's earlier map)} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}", has_selection, env_make_warp ), @@ -172,7 +172,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reset with Warp…", ["Object", "Envelope Distort"], None, - "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v} make the selected envelopes warp envelopes, keeping their content", + "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v, version?: as makeWithWarp (unset: a warp envelope's own, else 1)} make the selected envelopes warp envelopes, keeping their content", has_envelope, env_reset_warp ), @@ -190,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> { "Envelope Info", [], None, - "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}", + "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, version (a warp's map: 1, each style as one Bézier patch; 0, the earlier map), rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}", has_doc, env_info ), @@ -208,7 +208,7 @@ pub fn specs() -> Vec<CommandSpec> { "Envelope Options…", ["Object", "Envelope Distort"], None, - "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", + "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical, version?: 0|1 (warp envelopes' map, as makeWithWarp)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", has_doc, env_options ), diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 3412023280..b9a315325b 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -13,9 +13,14 @@ const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verti fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<EnvelopeKind> { check_numbers(p, WARP_NUMBERS, cmd)?; - let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true); - if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur { - (style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0); + // A new warp takes its style's Bézier net; an edited one keeps the map it has unless `version` + // names one. + let (mut style, mut bend, mut h, mut v, mut horizontal, mut version) = ("arc".to_string(), 50.0, 0.0, 0.0, true, live::WARP_NET); + if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0, version: n0 }) = cur { + (style, bend, h, v, horizontal, version) = (s0.clone(), *b0, *h0, *v0, *z0, *n0); + } + if let Some(n) = p.get("version") { + version = live::warp_version(n).ok_or_else(|| bad(cmd, format!("`version` must be 0 or {}", live::WARP_NET)))?; } if let Some(s) = text_param(p, "style", cmd)? { let s = s.strip_prefix("warp.").unwrap_or(s); @@ -32,7 +37,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop Some(o) => o == "horizontal", None => given, }; - Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal }) + Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal, version }) } /// The options and fidelity new envelopes get (Envelope Options with no envelope selected). @@ -206,8 +211,8 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result<Value> { return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity)); }; let v = match &**kind { - EnvelopeKind::Warp { style, bend, h, v, horizontal } => { - json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal}) + EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => { + json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal, "version": version}) } EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}), EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}), 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/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index 1a36a4e29f..296cef4dc2 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -9,6 +9,10 @@ //! from the tool's options (Objects on Path Tool Options). `object.objectsOnPath.options` sets the //! group's Pivot, Rotate and Space (the distance between the objects along the path, and where the //! first one attaches), and `object.objectsOnPath.expand` makes it a plain group. +//! `object.objectsOnPath.selectMember` chooses one object of a selected group (its Select/Move +//! widget), and `object.objectsOnPath.moveMember` moves one object to another's place along the +//! path, the objects between it and that place each moving one place toward where it was; hidden +//! and locked objects keep their places. //! //! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back //! on its path: editing the path moves them, an object moved on its own goes back to its place, @@ -19,7 +23,7 @@ //! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution, //! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; @@ -28,7 +32,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; use super::edit::{roots_of, selected_roots}; -use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_selection}; +use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_doc, has_selection}; /// The id of the Objects on Path tool. pub const TOOL: &str = "objectsOnPath"; @@ -53,6 +57,24 @@ pub fn specs() -> Vec<CommandSpec> { has_objects_on_path, options ), + cmd!( + "object.objectsOnPath.selectMember", + "Select Object on Path", + [], + None, + "{member: id of an object of an Objects on Path group} select the group alone with that object chosen, as a click on the object's Select/Move widget does; any other selection change clears the choice → {group, member}", + has_doc, + select_member + ), + cmd!( + "object.objectsOnPath.moveMember", + "Move Object on Path", + [], + None, + "{member: id of an object of an Objects on Path group, target: id of another object of the same group, group?: the group's id (when given, it must be the group of both); neither object nor the group may be hidden or locked} move the object to the target's place along the path; the objects between them each move one place toward where it was, except hidden and locked objects, which keep their places. Only the order along the path changes: Space, the start, Pivot, Rotate and the order in the Layers panel stay. A target that is the object itself changes nothing → {group, member, index}", + has_doc, + move_member + ), cmd!( "object.objectsOnPath.expand", "Expand", @@ -296,6 +318,71 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() })) } +// ---------- Select/Move ---------- + +/// The Objects on Path group whose object `key` of `p` names, checked: an object listed in the +/// group's record (not its path), in a group that is not hidden or locked. +fn member_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<(NodeId, NodeId)> { + let v = p.get(key).filter(|v| !v.is_null()).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?; + let id = checked_id(s, v, cmd)?; + let d = &s.doc()?.doc; + let not_on_path = || bad(cmd, format!("`{key}` must name an object attached to the path of an Objects on Path group")); + let g = + d.ancestry(id).unwrap_or_default().into_iter().rev().nth(1).filter(|g| d.node(*g).is_some_and(is_objects_on_path)).ok_or_else(not_on_path)?; + let listed = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref()).is_some_and(|rec| rec.members.iter().any(|m| m.id == id)); + if !listed { + return Err(not_on_path()); + } + if !d.is_editable(g) { + return Err(bad(cmd, "the Objects on Path group is hidden or locked")); + } + if !d.is_editable(id) { + return Err(bad(cmd, format!("`{key}` names an object that is hidden or locked"))); + } + Ok((g, id)) +} + +fn select_member(s: &mut Session, p: &Value) -> Result<Value> { + let (g, member) = member_param(s, p, "member", "object.objectsOnPath.selectMember")?; + s.select(|_, sel| sel.set_on_path_member(g, member))?; + Ok(json!({ "group": g.0, "member": member.0 })) +} + +fn move_member(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "object.objectsOnPath.moveMember"; + let (g, member) = member_param(s, p, "member", C)?; + let (target_group, target) = member_param(s, p, "target", C)?; + if target_group != g { + return Err(bad(C, "`target` must be an object of the same Objects on Path group as `member`")); + } + if let Some(v) = p.get("group").filter(|v| !v.is_null()) + && checked_id(s, v, C)? != g + { + return Err(bad(C, "`group` must be the Objects on Path group of `member` and `target`")); + } + let at = |s: &Session| -> Result<usize> { + let rec = s.doc()?.doc.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?; + rec.members.iter().position(|m| m.id == member).ok_or(EngineError::NoNode(member)) + }; + // Onto its own place: nothing changes, and no undo step is recorded. + if target == member { + return Ok(json!({ "group": g.0, "member": member.0, "index": at(s)? })); + } + // Hidden and locked objects between them keep their places. + let index = s.edit("Move Object on Path", |d, _| { + let mut rec = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?; + let fixed: HashSet<NodeId> = { + let editable = d.editable(); + rec.members.iter().map(|m| m.id).filter(|&id| !editable(id)).collect() + }; + let index = rec.move_member(member, target, |id| fixed.contains(&id)).ok_or(EngineError::NoNode(member))?; + let n = d.node_mut(g).ok_or(EngineError::NoNode(g))?; + n.extras_mut().objects_on_path = Some(Box::new(rec)); + Ok(index) + })?; + Ok(json!({ "group": g.0, "member": member.0, "index": index })) +} + // ---------- Expand ---------- fn expand(s: &mut Session, p: &Value) -> Result<Value> { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b199f9a356..c3b24463ae 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> { "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) ), diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 4b8ae5d2be..60fa90c32c 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -317,6 +317,14 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") { return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`")); } + // A Warp's map is one this build draws: 0, the earlier map, or its style's Bézier net. + let newest = vectorcraft_render::effects::WARP_NET; + if effect.starts_with("warp.") + && let Some(v) = given.get("version") + && vectorcraft_render::effects::warp_version(v).is_none() + { + return Err(bad(id, format!("`version` must be 0 or {newest}"))); + } check_effect_color(id, effect, params)?; check_effect_channel(id, effect, params) } diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs index 8fd2a0ea5e..f69e4b889e 100644 --- a/crates/engine/src/cmd/patternlib.rs +++ b/crates/engine/src/cmd/patternlib.rs @@ -26,6 +26,7 @@ pub struct BuiltinPatternLibrary { pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[ BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }, BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures }, + BuiltinPatternLibrary { id: "optical-effects", name: "Optical Effects", make: optical_effects }, ]; /// The library category of the built-in pattern libraries. @@ -256,6 +257,162 @@ fn grit_textures(d: &mut Document) { }); } +/// `n` + 1 points of the curve `(t, f(t))` for `t` from `t0` to `t1`, evenly spaced. +fn curve(t0: f64, t1: f64, n: u32, f: impl Fn(f64) -> f64) -> Vec<(f64, f64)> { + (0..=n) + .map(|i| { + let t = t0 + (t1 - t0) * f64::from(i) / f64::from(n); + (t, f(t)) + }) + .collect() +} + +/// The closed band between two curves drawn from the same side to the same side: `upper` forward, +/// then `lower` back. +fn band(upper: &[(f64, f64)], lower: &[(f64, f64)]) -> PathData { + let points: Vec<(f64, f64)> = upper.iter().chain(lower.iter().rev()).copied().collect(); + polygon(&points) +} + +/// 72 corners of the circle of radius `r` about (`cx`, `cy`), turning the other way with `reverse`. +fn circle(cx: f64, cy: f64, r: f64, reverse: bool) -> SubPath { + const CORNERS: u32 = 72; + let turn = if reverse { -std::f64::consts::TAU } else { std::f64::consts::TAU }; + let points: Vec<Point> = (0..CORNERS) + .map(|i| { + let a = turn * f64::from(i) / f64::from(CORNERS); + Point::new(cx + r * a.cos(), cy + r * a.sin()) + }) + .collect(); + SubPath::polyline(&points, true) +} + +/// The ring from radius `inner` to `outer` about (`cx`, `cy`): the outer circle and the inner one +/// turning the other way, so the middle stays clear under either fill rule. An `inner` of 0 gives +/// a disk. +fn ring(cx: f64, cy: f64, inner: f64, outer: f64) -> PathData { + let mut subpaths = vec![circle(cx, cy, outer, false)]; + if inner > 0.0 { + subpaths.push(circle(cx, cy, inner, true)); + } + PathData::new(subpaths) +} + +/// A lens `2 × l` long and `2 × h` high about (`cx`, `cy`), lying along x: two half sine waves that +/// meet at its tips, turning the other way with `reverse`. +fn lens(cx: f64, cy: f64, l: f64, h: f64, reverse: bool) -> SubPath { + const STEPS: u32 = 32; + let at = |i: u32, side: f64| { + let t = f64::from(i) / f64::from(STEPS); + Point::new(cx - l + 2.0 * l * t, cy + side * h * (std::f64::consts::PI * t).sin()) + }; + let mut points: Vec<Point> = (0..=STEPS).map(|i| at(i, -1.0)).chain((1..STEPS).rev().map(|i| at(i, 1.0))).collect(); + if reverse { + points.reverse(); + } + SubPath::polyline(&points, true) +} + +/// Optical Effects: black waves, rings, warped checks, pinched stripes, chevrons and lenses on a +/// clear background. Each tile is one period of its design, so a band that leaves one edge comes +/// back at the same place on the opposite edge. Art that reaches past an edge is kept whole: the +/// tiling draws the part outside the tile on the next tile. +fn optical_effects(d: &mut Document) { + use std::f64::consts::TAU; + // Three bands 5 pt wide, 12 pt apart, each one sine wave across the tile. + let bands = (0..3u32) + .map(|k| { + let mid = move |x: f64| 6.0 + 12.0 * f64::from(k) + 3.0 * (TAU * x / 36.0).sin(); + band(&curve(0.0, 36.0, 48, |x| mid(x) - 2.5), &curve(0.0, 36.0, 48, |x| mid(x) + 2.5)) + }) + .collect(); + add(d, "Sine Bands", 36.0, 36.0, TileType::Grid, bands); + // Nine thin waves 4 pt apart, two waves across the tile, each a ninth of a wave on from the one + // above it, so the ninth leads into the first wave of the next tile down. + let lines = (0..9u32) + .map(|k| { + let (y, phase) = (2.0 + 4.0 * f64::from(k), TAU * f64::from(k) / 9.0); + let mid = move |x: f64| y + 2.5 * (TAU * x / 18.0 + phase).sin(); + band(&curve(0.0, 36.0, 72, |x| mid(x) - 0.6), &curve(0.0, 36.0, 72, |x| mid(x) + 0.6)) + }) + .collect(); + add(d, "Ripple Lines", 36.0, 36.0, TileType::Grid, lines); + // Thin rings 3 pt apart about the center and rings 3.3 pt apart about a point 4 pt to its + // right. The two sets cross, and the outer rings reach into the next tile across. + let rings = (1..=7u32) + .map(|k| (24.0, 3.0 * f64::from(k))) + .chain((1..=7u32).map(|k| (28.0, 3.3 * f64::from(k)))) + .map(|(cx, r)| ring(cx, 24.0, r - 0.6, r + 0.6)) + .collect(); + add(d, "Interference Rings", 48.0, 48.0, TileType::Grid, rings); + // Rings 2 pt and 1 pt wide by turns about the tile's corner and about its center, the center's + // starting with the narrower one. The corner's rings are the same rings at all four corners. + let mut rings = vec![]; + for (c, first) in [(0.0, 0), (20.0, 1)] { + let mut r = 0.0; + for k in 0..5 { + let width = if (k + first) % 2 == 0 { 2.0 } else { 1.0 }; + rings.push(ring(c, c, r, r + width)); + r += width + 1.2; + } + } + add(d, "Offset Rings", 40.0, 40.0, TileType::Grid, rings); + // An 8 × 8 checkerboard of 4 pt cells whose columns are bent by a sine wave down the tile and + // whose rows by one across it, each edge drawn through five points. + let warp = |x: f64, y: f64| (x + 2.0 * (TAU * y / 32.0).sin(), y + 2.0 * (TAU * x / 32.0).sin()); + let cells = (0..8u32) + .flat_map(|row| (0..8u32).map(move |col| (col, row))) + .filter(|(col, row)| (col + row) % 2 == 0) + .map(|(col, row)| { + let (x, y) = (4.0 * f64::from(col), 4.0 * f64::from(row)); + let corners = [(x, y), (x + 4.0, y), (x + 4.0, y + 4.0), (x, y + 4.0), (x, y)]; + let points: Vec<(f64, f64)> = corners + .windows(2) + .flat_map(|e| { + let (a, b) = (e[0], e[1]); + (0..4u32).map(move |i| { + let t = f64::from(i) / 4.0; + warp(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) + }) + }) + .collect(); + polygon(&points) + }) + .collect(); + add(d, "Warped Checks", 32.0, 32.0, TileType::Grid, cells); + // Six upright stripes 6 pt apart that widen from 0.8 pt to 5.6 pt and narrow again down the tile, + // each a sixth of a wave on from the one to its left. + let stripes = (0..6u32) + .map(|k| { + let (x, phase) = (3.0 + 6.0 * f64::from(k), TAU * f64::from(k) / 6.0); + let half = move |y: f64| 1.6 + 1.2 * (TAU * y / 36.0 + phase).cos(); + let side = |s: f64| -> Vec<(f64, f64)> { curve(0.0, 36.0, 48, |y| x + s * half(y)).into_iter().map(|(y, at)| (at, y)).collect() }; + band(&side(-1.0), &side(1.0)) + }) + .collect(); + add(d, "Pinched Stripes", 36.0, 36.0, TileType::Grid, stripes); + // Four zigzag bands 8 pt apart, two zigzags across the tile; the bend and the width of the bands + // rise and fall from band to band. + let chevrons = [(0.0, 3.0, 4.0), (8.0, 4.5, 2.5), (16.0, 3.0, 1.0), (24.0, 1.5, 2.5)] + .into_iter() + .map(|(y, bend, width)| { + let upper: Vec<(f64, f64)> = (0..=4u32).map(|i| (8.0 * f64::from(i), if i % 2 == 1 { y + bend } else { y })).collect(); + let lower: Vec<(f64, f64)> = upper.iter().map(|&(x, y)| (x, y + width)).collect(); + band(&upper, &lower) + }) + .collect(); + add(d, "Chevron Pulse", 32.0, 32.0, TileType::Grid, chevrons); + // Five nested lens outlines 1.5 pt wide about the center, 14 pt to 78 pt long. The longer ones + // reach across the side edges and cross the next tiles' lenses. + let lenses = (0..5u32) + .map(|k| { + let (l, h) = (7.0 + 8.0 * f64::from(k), 4.0 + 4.0 * f64::from(k)); + PathData::new(vec![lens(24.0, 24.0, l, h, false), lens(24.0, 24.0, l - 1.5, h - 1.5, true)]) + }) + .collect(); + add(d, "Radiating Lenses", 48.0, 48.0, TileType::Grid, lenses); +} + #[cfg(test)] mod tests { use super::*; @@ -326,4 +483,101 @@ mod tests { } assert!(crossing > 0, "no mark reaches past an edge"); } + + const OPTICAL: [&str; 8] = + ["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"]; + + fn optical_doc() -> Document { + let mut d = Document::new(100.0, 100.0); + optical_effects(&mut d); + d + } + + /// The paths of pattern `p`'s art, each with its fill rule. + fn paths(p: &PatternDef) -> Vec<(&PathData, vectorcraft_geom::FillRule)> { + p.art + .iter() + .map(|n| match &n.kind { + vectorcraft_doc::NodeKind::Path { path, rule, .. } => (path, *rule), + _ => panic!("{}: art that is not a path", p.name), + }) + .collect() + } + + #[test] + fn optical_effects_is_a_pattern_library_of_eight_bounded_tiles() { + let b = PATTERN_LIBRARIES.iter().find(|b| b.id == "optical-effects").expect("Optical Effects is listed"); + assert_eq!(b.name, "Optical Effects"); + let names: Vec<String> = library_doc(b.id).unwrap().patterns.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, OPTICAL); + // Made twice, the art is the same. + let (a, again) = (optical_doc(), optical_doc()); + assert_eq!(a.patterns, again.patterns); + for p in &a.patterns { + let (w, h) = (p.width(), p.height()); + assert!((32.0..=48.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name); + assert_eq!(p.tile_type, TileType::Grid, "{}", p.name); + let paths = paths(p); + let segments: usize = paths.iter().flat_map(|(path, _)| &path.subpaths).map(SubPath::segment_count).sum(); + assert!(!paths.is_empty() && paths.len() <= 600 && segments <= 20_000, "{}: {} paths, {segments} segments", p.name, paths.len()); + for (path, _) in &paths { + let b = path.bounds().unwrap_or_else(|| panic!("{}: an empty path", p.name)); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name); + assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name); + } + } + } + + /// Just either side of a tile's right and bottom edges, the tiling paints the same points: a band + /// cut off at an edge, or one that comes back at another height or width, paints one side only. + /// No path is another moved by a whole tile, which the tiling would draw twice in one place. + #[test] + fn optical_tiles_join_without_a_seam() { + const SAMPLES: u32 = 400; + const STEP: f64 = 1e-4; + for p in &optical_doc().patterns { + let (w, h) = (p.width(), p.height()); + let shapes: Vec<(BezPathFill, Rect)> = + paths(p).into_iter().map(|(path, rule)| ((path.to_bezpath(), rule), path.bounds().unwrap_or(Rect::ZERO))).collect(); + let paints = |q: Point| { + let near = Rect::new(q.x - STEP, q.y - STEP, q.x + STEP, q.y + STEP); + p.offsets_covering(near).into_iter().any(|o| { + let at = q - o; + shapes.iter().any(|((bp, rule), b)| { + let inside = b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1; + let turns = if inside { kurbo::Shape::winding(bp, at) } else { 0 }; + match rule { + vectorcraft_geom::FillRule::EvenOdd => turns % 2 != 0, + vectorcraft_geom::FillRule::NonZero => turns != 0, + } + }) + }) + }; + let (mut differ, mut painted) = (0, 0); + for i in 0..SAMPLES { + let t = (f64::from(i) + 0.37) / f64::from(SAMPLES); + let (right, bottom) = (Point::new(w, t * h), Point::new(t * w, h)); + let (dx, dy) = (kurbo::Vec2::new(STEP, 0.0), kurbo::Vec2::new(0.0, STEP)); + for (a, b) in [(right - dx, right + dx), (bottom - dy, bottom + dy)] { + let (pa, pb) = (paints(a), paints(b)); + differ += usize::from(pa != pb); + painted += usize::from(pa); + } + } + assert!(differ <= 2, "{}: {differ} of {} points across the edges differ", p.name, 2 * SAMPLES); + assert!(painted > 0, "{}: nothing crosses an edge", p.name); + let bounds: Vec<Rect> = shapes.iter().map(|(_, b)| *b).collect(); + let same = |a: &Rect, b: Rect| { + (a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9 + }; + for b in &bounds { + for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] { + let moved = *b + kurbo::Vec2::new(dx, dy); + assert!(!bounds.iter().any(|o| same(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name); + } + } + } + } + + type BezPathFill = (vectorcraft_geom::BezPath, vectorcraft_geom::FillRule); } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index a6e7f67286..5d56a2b646 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> { "Image Info", [], None, - "{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}", + "{id?} an image object (default: the one selected) → {id, name, linked, link (the linked file's path; null when embedded, or when automation read roots are in force and the file is outside them), colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}", has_doc, image_info ), @@ -891,11 +891,13 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result<Value> { let [a, b, c, d, ..] = im.xf.as_coeffs(); let ppi = |x: f64, y: f64| Some(x.hypot(y)).filter(|l| *l > 1e-12).map_or(0.0, |l| 72.0 / l); let size = n.geometric_bounds().unwrap_or_default(); + // A linked file outside the read roots of the caller asking now is left unnamed, as in Link Info. + let doc_path = super::links::LinkBase::of(st).doc; Ok(json!({ "id": id.0, "name": n.display_name(), "linked": im.link.is_some(), - "link": im.link.as_ref().map(|l| &l.path), + "link": im.link.as_ref().map(|l| &l.path).filter(|path| super::links::path_shown(path, doc_path)), "colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)), "pixelWidth": im.width, "pixelHeight": im.height, diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 2d16bbc683..dfa44a1436 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -62,13 +62,28 @@ fn err(e: vectorcraft_plugins::Error) -> EngineError { } } +/// Under automation read roots, a reply names a file or folder outside them by its name alone. +fn shown_file(path: &str) -> String { + super::fileio::shown_path(path).into_owned() +} + +/// A plug-in turned off for the next launch, or the one a launch stopped at, as a reply shows it: a +/// file outside the automation read roots is named by its file name. +fn shown_item(item: &safe_mode::Item) -> Value { + let mut v = item.to_json(); + if let Some(file) = item.file.as_deref() { + v["file"] = json!(shown_file(file)); + } + v +} + /// JSON description of an installed plug-in. pub fn describe(p: &Plugin) -> Value { let m = p.manifest(); let mut v = json!({ "id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description, "params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()), - "size": p.size(), "source": p.source(), + "size": p.size(), "source": p.source().map(shown_file), "permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api, }); match m.kind { @@ -932,9 +947,46 @@ fn describe_installed(p: &Plugin) -> Value { v } +/// The folder load `report` as a reply shows it: under automation read roots, a folder outside them +/// is `null` and a failed file outside them is named by its file name, in the errors too. +fn shown_report(mut report: Value) -> Value { + let mut hidden: Vec<(String, String)> = vec![]; + if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) { + for row in failed { + let Some(file) = row.get("file").and_then(Value::as_str).map(str::to_string) else { continue }; + let shown = shown_file(&file); + if shown != file { + row["file"] = json!(shown); + hidden.push((file, shown)); + } + } + } + if let Some(folder) = report.get("folder").and_then(Value::as_str).map(str::to_string) { + let shown = shown_file(&folder); + if shown != folder { + report["folder"] = Value::Null; + hidden.push((folder, shown)); + } + } + // A failed file's path holds its folder's: the longer paths are put back first. + hidden.sort_by_key(|(path, _)| std::cmp::Reverse(path.len())); + let unnamed = |text: &str| hidden.iter().fold(text.to_string(), |t, (path, name)| t.replace(path.as_str(), name)); + if let Some(e) = report.get("error").and_then(Value::as_str).map(unnamed) { + report["error"] = json!(e); + } + if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) { + for row in failed { + if let Some(e) = row.get("error").and_then(Value::as_str).map(unnamed) { + row["error"] = json!(e); + } + } + } + report +} + fn list(_: &mut Session, _: &Value) -> Result<Value> { - let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); - let disabled: Vec<Value> = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect(); + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| shown_report(r.clone())); + let disabled: Vec<Value> = safe_mode::disabled().iter().map(shown_item).collect(); Ok( json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::<Vec<_>>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), ) @@ -946,11 +998,33 @@ fn safe_mode_state(_: &mut Session, _: &Value) -> Result<Value> { Ok(json!({ "active": safe_mode::active(), "source": safe_mode::source().map(safe_mode::Source::name), - "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::<Vec<_>>(), - "pendingItem": safe_mode::previous().map(|i| i.to_json()), + "disabled": safe_mode::disabled().iter().map(shown_item).collect::<Vec<_>>(), + "pendingItem": safe_mode::previous().map(|i| shown_item(&i)), })) } +/// The file of a plug-in `plugin.safeMode` lists as `file`, or `plugin.list` as `source`: one they name +/// by its file name (outside the automation read roots) is found by that name, when no other listed +/// file has it. +fn listed_file(file: String) -> String { + let mut listed: Vec<String> = safe_mode::disabled() + .into_iter() + .chain(safe_mode::previous()) + .filter_map(|i| i.file) + .chain(registry::list().iter().filter_map(|p| p.source().map(str::to_string))) + .collect(); + listed.sort(); + listed.dedup(); + if listed.contains(&file) { + return file; + } + let mut named = listed.into_iter().filter(|f| shown_file(f) == file); + match (named.next(), named.next()) { + (Some(f), None) => f, + _ => file, + } +} + /// `plugin.setEnabled`: turns a plug-in on or off for the next launch, by `id` or by `file` (a /// plug-in that stopped a launch while loading may be known by its file alone). It neither loads /// nor unloads anything now, and uninstalls nothing. @@ -972,7 +1046,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> { let stored = if enabled { s.stored_plugin_file(&id).map(safe_mode::Item::file) } else { None }; (registry::get(&id).map(|p| safe_mode::Item::of(&p)).unwrap_or(safe_mode::Item { id: Some(id), ..Default::default() }), stored) } - (None, Some(file)) => (safe_mode::Item::file(file), None), + (None, Some(file)) => (safe_mode::Item::file(listed_file(file)), None), (None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")), }; let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect(); @@ -986,7 +1060,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> { // Without a plug-in folder (a session confined to automation roots), or when the list there // can't take the change, the change stays in this process and goes with the next change that saves. let saved = !changed || s.store_disabled_plugins(); - Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::<Vec<_>>()})) + Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(shown_item).collect::<Vec<_>>()})) } fn info(_: &mut Session, p: &Value) -> Result<Value> { @@ -1055,7 +1129,7 @@ pub fn specs() -> Vec<CommandSpec> { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null (under automation read roots, a folder outside them is null, and a failed file and a plug-in's source outside them are named by their file name), safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -1127,7 +1201,7 @@ pub fn specs() -> Vec<CommandSpec> { "Safe Mode", [], None, - "{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command", + "{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file; under automation read roots, a file outside them is named by its file name here and in pendingItem, and plugin.setEnabled takes that name), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command", always, safe_mode_state ), @@ -1136,7 +1210,7 @@ pub fn specs() -> Vec<CommandSpec> { "Enable on Re-launch", [], None, - "{id | file: a plug-in as plugin.safeMode's disabled lists it, enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}", + "{id | file: a plug-in as plugin.safeMode's disabled lists it or as plugin.list's source (under automation read roots, a file outside them by the file name shown, when no other listed file has it), enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}", always, set_enabled ), diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs index b151adcea2..4b8bc24e36 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<Location> { + 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<Location> { + 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,6 +165,8 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[ ("attributes.set", "url"), ("svgInteractivity.info", "files"), ("addon.cleanup.rejoin", "paths"), + ("object.slice.options", "url"), + ("document.exportForScreens", "folders"), ]; /// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`], @@ -466,7 +514,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() @@ -565,6 +614,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::<Vec<_>>()).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 4c9abdb357..94f865e224 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", @@ -307,7 +308,9 @@ pub(super) const ALLOWED: &[&str] = &[ "object.nudge", "object.objectsOnPath.attach", "object.objectsOnPath.expand", + "object.objectsOnPath.moveMember", "object.objectsOnPath.options", + "object.objectsOnPath.selectMember", "object.path.addAnchorPoints", "object.path.cleanUp", "object.path.divideObjectsBelow", @@ -645,6 +648,8 @@ pub(super) const ALLOWED: &[&str] = &[ "type.opticalMarginAlignment", "type.orientation.horizontal", "type.orientation.vertical", + "type.pathCenter.bottom", + "type.pathCenter.top", "type.pathOptions", "type.resetHorizontalScale", "type.resetTracking", diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index fce76bcc27..f0087595dd 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -97,6 +97,10 @@ pub trait RecoveryStore: Send + Sync { fn remove(&self, name: &str) -> std::result::Result<(), String>; /// Where the entries are, as the user knows it (a folder, "browser storage"). fn location(&self) -> String; + /// Is [`RecoveryStore::location`] a folder on this computer (default: no)? + fn in_folder(&self) -> bool { + false + } /// The areas in the store (default: those with entries). fn areas(&self) -> std::result::Result<Vec<String>, String> { let names = self.list()?; @@ -477,6 +481,9 @@ impl RecoveryStore for FolderStore { fn location(&self) -> String { self.0.to_string_lossy().into_owned() } + fn in_folder(&self) -> bool { + true + } fn areas(&self) -> std::result::Result<Vec<String>, String> { Self::names(&self.0, true) } @@ -938,6 +945,21 @@ fn sweep(store: &dyn RecoveryStore, area: &str) { } } +/// The document's file `path` as a reply names it: under automation read roots, only inside them. +fn shown_path(path: Option<String>) -> Option<String> { + path.filter(|p| crate::file_access::check_read(p).is_ok()) +} + +/// Where `store` keeps its copies, as a reply names it: a recovery folder outside the automation +/// read roots is `null`. +fn shown_location(store: &dyn RecoveryStore) -> Value { + let location = store.location(); + if store.in_folder() && crate::file_access::check_read(&location).is_err() { + return Value::Null; + } + json!(location) +} + fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta { store.read(&format!("{file}{META_EXT}")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() } @@ -1235,7 +1257,7 @@ fn save(s: &mut Session, _: &Value) -> Result<Value> { } forget_clean(s); heartbeat(s); - Ok(json!({ "saved": saved, "skipped": skipped, "location": store.location() })) + Ok(json!({ "saved": saved, "skipped": skipped, "location": shown_location(store.as_ref()) })) } /// Who an area of the store belongs to. @@ -1361,7 +1383,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> { let meta = read_meta(store.as_ref(), f); let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title }; rows.push(json!({ - "file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved, + "file": f, "title": title, "path": shown_path(meta.path), "format": meta.format, "saved": meta.saved, "open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running, })); } @@ -1370,7 +1392,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> { tidy(store.as_ref(), &area, hold); } } - Ok(json!({ "copies": rows, "location": store.location() })) + Ok(json!({ "copies": rows, "location": shown_location(store.as_ref()) })) } /// A gone app's area to act on: its name, the copies to act on, and its lock. @@ -1483,7 +1505,10 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R // The copy stays until the document is saved or closed. st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); let title = st.title(); - Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path, "warnings": warnings }), links.to_json())) + Ok(super::fileio::merge( + json!({ "index": index, "title": title, "file": mine, "path": shown_path(meta.path), "warnings": warnings }), + links.to_json(), + )) } fn restore(s: &mut Session, p: &Value) -> Result<Value> { @@ -1527,7 +1552,7 @@ pub fn specs() -> Vec<CommandSpec> { "Save Recovery Data", [], None, - "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)", + "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web; null under automation read roots when the folder is outside them)", has_store, save ), @@ -1536,7 +1561,7 @@ pub fn specs() -> Vec<CommandSpec> { "Recovery Data", [], None, - "{} the recovery copies in the store → {copies: [{file (\"<area>/<name>\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash", + "{} the recovery copies in the store → {copies: [{file (\"<area>/<name>\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash; under automation read roots, path is null when the document's file is outside them, and location when the recovery folder is", has_store, list ), @@ -1545,7 +1570,7 @@ pub fn specs() -> Vec<CommandSpec> { "Restore Recovered Documents", [], None, - "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed", + "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}; under automation read roots, path is null when the original file is outside them. The copy moves to this app's area and stays until the document is saved or closed", has_store, restore ), diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 8c86f0dfe3..44a14dbe34 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -233,6 +233,43 @@ impl Session { self.scripts_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32")) } + /// The last run's report ([`Session::script_report`]) as a reply shows it: under automation read + /// roots, a file the run wrote outside them (`written`) and the file a failed run stopped in + /// (`file`, also in the run's error line in `console`) are named by their file name. What the + /// script wrote itself (`log`, its output lines) stays as written. + pub fn shown_script_report(&self) -> Option<Value> { + let mut report = self.script_report()?.clone(); + if let Some(written) = report.get_mut("written").and_then(Value::as_array_mut) { + for path in written.iter_mut() { + if let Some(name) = path.as_str().and_then(hidden_name) { + *path = json!(name); + } + } + } + if let Some(file) = report.get("file").and_then(Value::as_str).map(str::to_string) + && let Some(name) = hidden_name(&file) + { + // The run's error lines come after every line the script wrote. They are written again + // from the report with the file's name, which also covers a line the console cut inside + // the path. A console that dropped its oldest lines may hold only the last of them. + let before = script::error_lines(&report); + report["file"] = json!(name); + if let Some(message) = report.get("message").and_then(Value::as_str) { + report["message"] = json!(message.replace(&file, &name)); + } + let after = script::error_lines(&report); + if let Some(lines) = report.pointer_mut("/console/lines").and_then(Value::as_array_mut) { + let kept = (1..=before.len().min(lines.len())) + .rev() + .find(|&n| lines.iter().rev().take(n).map(Value::as_str).eq(before.iter().rev().take(n).map(|l| Some(l.as_str())))) + .unwrap_or(0); + lines.truncate(lines.len() - kept); + lines.extend(after.iter().skip(after.len().saturating_sub(kept)).map(|l| json!(l))); + } + } + Some(report) + } + /// The text of the script at `path`, which a listing of the Scripts folder gave /// ([`script::folder::read`]), checked as `script.run` checks a file's. pub fn installed_script(&self, path: &str) -> Result<String> { @@ -248,6 +285,13 @@ impl Session { } } +/// The file name a reply names `path` by when the file is outside the automation read roots; `None` +/// when a reply may name it whole. +fn hidden_name(path: &str) -> Option<String> { + let shown = super::fileio::shown_path(path); + (shown != path).then(|| shown.into_owned()) +} + fn list_installed(s: &mut Session, _: &Value) -> Result<Value> { let Some(root) = s.scripts_dir() else { return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false})); diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index aeeac58855..a2c7b3a8d4 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> { "Not Aligned to Pixel Grid", ["Select", "Object"], None, - "{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups but not into compound paths → {count}", + "{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups, clip groups, blends, envelopes, repeats and Intertwine groups but not into compound paths → {count}", has_doc, not_pixel_aligned ), 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<u32> { /// 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<StopKey<'a>>); +/// 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<StopKey<'a>>); -/// 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<GradientKey<'_>> { let kind = match g.kind { GradientKind::Linear => 0, @@ -410,7 +411,7 @@ fn gradient_key(g: &Gradient) -> Option<GradientKey<'_>> { GradientMethod::Classic => 0, GradientMethod::Perceptual => 1, }; - Some((kind, method, g.dither, g.stops.iter().map(stop_key).collect::<Option<_>>()?)) + Some((kind, method, g.dither, g.painted_spread(), g.stops.iter().map(stop_key).collect::<Option<_>>()?)) } /// A stop's key for [`gradient_key`]; `None` when any of its numbers is NaN. diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index edfab850f5..891e518a33 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> { "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, 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<CommandSpec> { "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 ), @@ -466,6 +466,8 @@ pub(crate) struct CharChange { language: Option<Option<String>>, font_axes: Option<AxisChange>, font_height_reference: Option<vectorcraft_doc::FontHeightReference>, + /// 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<Option<Vec<String>>> { 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<Option<Vec<String>> 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<Option<Vec<String>> /// 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 @@ -756,6 +760,13 @@ 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")); @@ -809,6 +820,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 +963,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 +1284,9 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> { }; 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 +1307,16 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> { 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 +1324,14 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> { 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 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/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 06368c6c7d..e71267a2de 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> { "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, 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 ), @@ -1365,6 +1365,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> { #[cfg(test)] mod area_tests; mod block; +pub(crate) use block::needed_room; #[cfg(test)] mod block_tests; diff --git a/crates/engine/src/cmd/typecmd/block.rs b/crates/engine/src/cmd/typecmd/block.rs index 06453f4983..c10abbc965 100644 --- a/crates/engine/src/cmd/typecmd/block.rs +++ b/crates/engine/src/cmd/typecmd/block.rs @@ -89,6 +89,39 @@ fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collection out } +/// The room along its lines that a frame of `t`'s text needs for Block to keep the widest line as +/// typed at its present size: that line's width plus its paragraph's indents, as [`block_fit`] +/// measures them, and a margin of under half a size step so that the line is not made a step +/// smaller; with the widest line's first byte and its paragraph's index. A line whose negative +/// indents take more than its width needs its own width. None when no line has a width. +pub(crate) fn needed_room(t: &TextObject) -> Option<(f64, usize, usize)> { + let points = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12)?; + let text = t.plain_text(); + let lines = typed_lines(&text); + let widths = line_widths(vectorcraft_text::FontDb::global(), t); + let sizes = first_sizes(&t.runs, &lines); + lines + .iter() + .zip(&sizes) + .filter_map(|(l, size)| { + let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?; + let size = size * points; + if l.content == l.start || !(size.is_finite() && size > 0.0) { + return None; + } + let para = t.para_at(l.para); + let first = if l.para_start { para.first_line_indent } else { 0.0 }; + let indents = para.laid_out_left_indent() + para.right_indent + first; + // Negative indents that take more than the line's width leave the line no room at its + // size; the frame is then as wide as the line itself, and Block makes it larger. + let room = if width + indents > 1e-9 { width + indents } else { width }; + // A tenth of a point more size widens the line by 0.1 * width / size. + Some((room + 0.04 * width / size, l.start, l.para)) + }) + .filter(|r| r.0.is_finite() && r.0 > 1e-9) + .reduce(|a, b| if b.0 > a.0 { b } else { a }) +} + /// The size a line set at `size` points and `width` wide takes to fill `avail`, its width growing /// by `slope` a point of size (`width / size` when the whole line follows its size): rounded down /// to tenths and kept within [`SIZES`]. A line that fits and would not fit a tenth larger keeps its @@ -152,7 +185,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool { let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?; let para = t.para_at(l.para); let first = if l.para_start { para.first_line_indent } else { 0.0 }; - let avail = cell - para.left_indent - para.right_indent - first; + let avail = cell - para.laid_out_left_indent() - para.right_indent - first; let size = size * points; if !(size.is_finite() && size > 0.0) { return None; diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs index ec458a0c9b..0354a630b3 100644 --- a/crates/engine/src/cmd/typecmd/block_tests.rs +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -188,13 +188,25 @@ fn bad_params_and_other_type_are_refused() { assert!(!text(&s, id).dynamic_text); let point = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [point]})).unwrap(); + // Off leaves point type as it is, with no undo step, and a gap needs it turned on. + let n = undo_len(&s); + assert_eq!(s.execute("type.dynamicText", &json!({"on": false})).unwrap()["ids"], json!([point])); + let e = s.execute("type.dynamicText", &json!({"on": false, "wordGap": 200})).unwrap_err().to_string(); + assert!(e.contains("need area type"), "{e}"); + assert_eq!(undo_len(&s), n); + assert!(matches!(text(&s, point).kind, TextKind::Point)); + let shape = s.execute("shape.rectangle", &json!({"x": 500, "y": 500, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [shape]})).unwrap(); let e = s.execute("type.dynamicText", &json!({})).unwrap_err().to_string(); assert!(e.contains("select type on a path or area type"), "{e}"); - // With point type selected too, only the area type changes. + // With point type selected too, both turn on, the point type as area type with Block on. s.execute("select.set", &json!({"ids": [id, point]})).unwrap(); + let n = undo_len(&s); let r = s.execute("type.dynamicText", &json!({})).unwrap(); - assert_eq!(r["ids"], json!([id])); - assert!(text(&s, id).dynamic_text && !text(&s, point).dynamic_text); + assert_eq!(r["ids"], json!([id, point])); + assert!(text(&s, id).dynamic_text && text(&s, point).dynamic_text); + assert!(matches!(text(&s, point).kind, TextKind::Area { .. })); + assert_eq!(undo_len(&s), n + 1); // A gap with only type on a path selected is an error, and nothing changes. let path = s.execute("path.create", &json!({"d": "M60 550 L340 550"})).unwrap()["id"].as_u64().unwrap(); let along = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Along"})).unwrap()["id"].as_u64().unwrap(); @@ -438,6 +450,228 @@ fn threaded_frames_are_refused_and_threading_turns_block_off() { assert!(matches!(text(&s, a).kind, TextKind::Area { .. })); } +/// 12 pt point type at (300, 100) with `extra` params for `text.create`, selected. +fn point(s: &mut Session, text: &str, extra: Value) -> u64 { + let mut p = json!({"x": 300, "y": 100, "size": 12, "text": text}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + id +} + +/// Where the first glyph of the laid-out line starting at byte `start` is in the document. +fn line_start(t: &TextObject, start: usize) -> vectorcraft_geom::Point { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let l = lay.lines.iter().find(|l| l.start == start).expect("a line starts there"); + t.xf * lay.glyphs[l.glyph_start].origin +} + +#[test] +fn point_type_becomes_area_type_with_block_at_its_widest_line() { + for justify in ["left", "center", "right"] { + let mut s = session(); + let id = point(&mut s, "Short\nA much longer line", json!({})); + s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap(); + let before = line_start(&text(&s, id), 6); + let n = undo_len(&s); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!((r["on"].clone(), r["ids"].clone()), (json!(true), json!([id]))); + assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(100.0), json!(0.0)), "the reply gives the new frame's gaps"); + assert_eq!(undo_len(&s), n + 1, "{justify}: one undo step"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text"); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}"); + assert_eq!(t.area.fit, AreaFit::None); + assert_block(&t); + assert_eq!(size_at(&t, 6), 12.0, "{justify}: the widest line keeps its size"); + assert!(size_at(&t, 0) > 24.0, "{justify}: the short line grows to the frame's width: {}", size_at(&t, 0)); + // Across, the widest line stays where it was; the short line above it grew, so it moved down. + let after = line_start(&t, 6); + assert!((after.x - before.x).abs() < 0.05, "{justify}: the widest line stays where it was across: {before:?} → {after:?}"); + assert!(after.y > before.y + 12.0, "{justify}"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}: the frame holds every line"); + // Edits fit it again as area type; undo brings the point type back as it was. + s.execute("edit.undo", &json!({})).unwrap(); + let back = text(&s, id); + assert!(matches!(back.kind, TextKind::Point) && !back.dynamic_text && size_at(&back, 0) == 12.0, "{justify}"); + } +} + +#[test] +fn point_type_with_gaps_indents_and_vertical_type_turns_into_block() { + let mut s = session(); + // The gaps apply to the frame point type becomes. + let id = point(&mut s, "one\ntwo words here", json!({})); + let r = s.execute("type.dynamicText", &json!({"wordGap": 300, "lineGap": 4})).unwrap(); + assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(300.0), json!(4.0))); + let t = text(&s, id); + assert!(t.dynamic_text && (t.area.word_gap, t.area.line_gap) == (300.0, 4.0)); + assert_block(&t); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Indents: the frame takes them in, so the widest line keeps its size. + let id = point(&mut s, "Tiny\nThe widest line of all", json!({"y": 300})); + s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": 20, "firstLineIndent": 10})).unwrap(); + let before = line_start(&text(&s, id), 5); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_block(&t); + assert_eq!(size_at(&t, 5), 12.0); + assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5)); + // A nested list item: the frame takes in the level's indent too. + let id = point(&mut s, "Tiny\nThe widest item of all", json!({"y": 200})); + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "listLevel": 3})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_eq!(size_at(&t, 5), 12.0, "nested list"); + assert!(size_at(&t, 0) > 12.0); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Mixed alignments: the widest line keeps its size and its place. + let id = point(&mut s, "Left\nThe widest line, right aligned", json!({"y": 400})); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) { + let right = vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Right, ..t.para.clone() }; + t.set_paragraph_styles(vec![t.para.clone(), right]); + } + let before = line_start(&text(&s, id), 5); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_eq!(size_at(&t, 5), 12.0, "mixed alignments"); + assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5)); + // A right-to-left list, whose indents the layout mirrors. + let id = point(&mut s, "קצר\nשורה ארוכה בהרבה מהראשונה", json!({"y": 450})); + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "rightIndent": 9})).unwrap(); + let widest = "קצר\n".len(); + let before = line_start(&text(&s, id), widest); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_eq!(size_at(&t, widest), 12.0, "right-to-left list"); + assert!((line_start(&t, widest).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, widest)); + // Point type that was area type with its own options: the new frame takes the defaults but the + // gaps. + let id = point(&mut s, "Was\nonce a frame of two columns", json!({"y": 500})); + s.execute("type.convertToAreaType", &json!({"ids": [id]})).unwrap(); + s.execute( + "text.areaOptions", + &json!({"columns": 2, "gutter": 12, "inset": 6, "verticalAlign": "bottom", "firstBaselineMin": 40, "wordGap": 150}), + ) + .unwrap(); + s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap(); + let before = line_start(&text(&s, id), 4); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + let defaults = vectorcraft_doc::AreaOptions { word_gap: 150.0, ..Default::default() }; + assert_eq!(t.area, defaults); + assert_eq!(size_at(&t, 4), 12.0, "options from an earlier frame"); + assert!((line_start(&t, 4).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 4)); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Vertical type: the frame is as tall as the longest column and grows to the left. + let id = point(&mut s, "縦書き\nもっと長い縦書きの行", json!({"x": 600, "y": 40, "vertical": true})); + let before = line_start(&text(&s, id), "縦書き\n".len()); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.vertical && t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_block(&t); + assert_eq!(size_at(&t, "縦書き\n".len()), 12.0); + let after = line_start(&t, "縦書き\n".len()); + assert!((after.y - before.y).abs() < 0.05, "the longest column stays where it was down the page: {before:?} → {after:?}"); + assert!(after.x < before.x - 12.0, "the short column before it grew, so it moved left: {before:?} → {after:?}"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); +} + +#[test] +fn point_type_with_negative_indents_wider_than_its_line_turns_into_block() { + // Block makes the line larger; it keeps its start, center or end by its alignment. + let ends = |t: &TextObject| { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let l = &lay.lines[0]; + ((t.xf * vectorcraft_geom::Point::new(l.x0, l.baseline)).x, (t.xf * vectorcraft_geom::Point::new(l.x1 - l.trailing, l.baseline)).x) + }; + for justify in ["left", "center", "right"] { + let mut s = session(); + let id = point(&mut s, "Short", json!({})); + s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": -200})).unwrap(); + let (b0, b1) = ends(&text(&s, id)); + let n = undo_len(&s); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "{justify}"); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}: the frame is as wide as the line"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}"); + assert!(size_at(&t, 0) > 12.0, "{justify}: Block makes the line larger"); + let (a0, a1) = ends(&t); + let kept = match justify { + "left" => (a0, b0), + "center" => ((a0 + a1) / 2.0, (b0 + b1) / 2.0), + _ => (a1, b1), + }; + assert!((kept.0 - kept.1).abs() < 0.05, "{justify}: the line keeps its aligned point: {b0},{b1} → {a0},{a1}"); + } +} + +#[test] +fn point_type_under_a_text_wrap_object_turns_into_block_that_holds_every_line() { + let mut s = session(); + let id = point(&mut s, "One\nTwo\nThree\nA much longer line than the rest", json!({})); + // A wrap object above the type, across the top of the frame it becomes. + let r = s.execute("shape.rectangle", &json!({"x": 250, "y": 80, "width": 400, "height": 40})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert!(!t.wrap.is_empty(), "the new area type takes the wrap object"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame holds every line around the wrap"); + // A wrap object deeper than any size Block sets, across the whole frame: the lines go below it. + let id = point(&mut s, "Below", json!({"x": 1000, "y": 100})); + let r = s.execute("shape.rectangle", &json!({"x": 900, "y": 80, "width": 400, "height": 9000})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && !t.wrap.is_empty()); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches past a deep wrap object"); + // An inverted wrap object far below the type holds the lines inside it: the frame reaches it. + let id = point(&mut s, "Inside", json!({"x": 1600, "y": 100})); + let r = s.execute("shape.rectangle", &json!({"x": 1550, "y": 9000, "width": 400, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("object.textWrap.options", &json!({"invert": true})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && !t.wrap.is_empty()); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches a distant inverted wrap object"); +} + +#[test] +fn point_type_with_nothing_to_fit_is_left_as_it_is() { + let mut s = session(); + for blank in ["", " ", "\n\n"] { + let id = point(&mut s, blank, json!({})); + let before = text(&s, id); + let n = undo_len(&s); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!(r["on"], json!(true)); + assert_eq!(undo_len(&s), n, "{blank:?}: no undo step"); + assert_eq!(text(&s, id), before, "{blank:?}"); + // A gap needs type it can apply to. + let e = s.execute("type.dynamicText", &json!({"wordGap": 200})).unwrap_err().to_string(); + assert!(e.contains("need area type"), "{blank:?}: {e}"); + } + // A line far shorter than the widest one stops at the size limit, and the frame still holds it. + let id = point(&mut s, &format!(".\n{}", "w".repeat(400)), json!({"size": 6})); + s.execute("type.dynamicText", &json!({})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text); + assert_eq!(size_at(&t, 0), 1296.0); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); +} + #[test] fn long_text_fits_in_a_bounded_number_of_layouts() { let mut s = session(); @@ -451,4 +685,16 @@ fn long_text_fits_in_a_bounded_number_of_layouts() { // pass and the caches), plus the reply's own. let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 4; assert!(used <= bound, "{used} layouts"); + // Long point type: measured, fitted in its frame and fitted again at its depth, with the + // layouts that place and measure the frame. + let id = point(&mut s, &story, json!({})); + let before = vectorcraft_text::test_fonts::layouts_on_this_thread(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let used = vectorcraft_text::test_fonts::layouts_on_this_thread() - before; + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_block(&t); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 8; + assert!(used <= bound, "point type: {used} layouts"); } diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 239ee748bf..faada86d5f 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, No use vectorcraft_geom::{Affine, Rect, shapes}; use super::object::choice; -use super::typecmd::{area_text, block_gaps, refresh_bounds}; +use super::typecmd::{area_text, block_gaps, needed_room, refresh_bounds}; use super::{ CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or, switch_or, targets, text_param, @@ -60,12 +60,30 @@ pub fn specs() -> Vec<CommandSpec> { has_selection, path_options ), + cmd!( + "type.pathCenter.top", + "Center on Top", + ["Type", "Type on a Path"], + None, + "{ids?|id?} slide the selected type on a closed path along it so that the middle of the span between its start and end brackets lands on the path's topmost point as the object is drawn (after its transform, so a rotated, scaled or skewed path is measured as it appears; on a level stretch such as a rectangle's top edge, its middle; with several separate topmost points, the middle of the shortest part of the path that holds them all), keeping the span's length (a full turn stays a full turn), and set the first paragraph's alignment to Center unless it is Center or Justify Center, so the type is centered there. Point type and area type in the selection are left as they are. Type on an open or compound path, type in a thread, and type on a path with no length or no height are refused with an error that names them, and nothing changes. When every object is already centered there, nothing changes and no undo step is recorded → {ids, start, end} (the first object's brackets)", + has_closed_path_type, + path_center_top + ), + cmd!( + "type.pathCenter.bottom", + "Center on Bottom", + ["Type", "Type on a Path"], + None, + "{ids?|id?} as type.pathCenter.top, on the path's bottommost point as drawn. The type is not flipped: on the bottom of a path that runs clockwise it reads upside down until Type on a Path Options › Flip turns it → {ids, start, end} (the first object's brackets)", + has_closed_path_type, + path_center_bottom + ), cmd!( "type.dynamicText", "Dynamic Text", ["Type"], None, - "{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}", + "{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type, or turn it on for point type (on omitted: the opposite of the first object's setting, point type counting as off, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection, and no point type it turns into area type, is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Turning it on turns point type into area type with Block on, with the default Area Type Options but the gaps (options kept from an earlier frame do not carry over): the frame is as wide (along the lines) as the widest line as typed with its paragraph's indents, so that line keeps its size and sits where it was (by its own alignment), the other lines are set to that width (when negative indents take more than that line's width, the frame is as wide as the line, and Block makes it larger), and the frame then grows in depth (down, or to the left for vertical type) to hold every fitted line, around any Text Wrap objects above it; the gaps apply to it as to area type. Point type with no line to fit (empty, or only spaces and breaks) is left as it is, and so is point type when it is turned off; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}", has_dynamic_type, dynamic_text ), @@ -328,13 +346,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> { // A little slack on the right so the text doesn't rewrap. let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0); let slack = size * 0.5 + trailing; - // Slack goes where the alignment leaves room, so the text doesn't move or rewrap. - let (sl, sr) = match t.para_at(0).justify { - Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), - Justify::Right | Justify::JustifyRight => (slack, 0.0), - Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0), - _ => (0.0, slack), - }; + let (sl, sr) = slack_sides(t.para_at(0).justify, lay.lines.first().is_some_and(|l| l.rtl), slack); let frame = lay.line_xf.transform_rect_bbox(Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0))); t.kind = TextKind::Area { frame: shapes::rectangle(frame) }; refresh_bounds(t); @@ -344,6 +356,107 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> { Ok(ids_json(&ids)) } +/// How `slack` added to point type's lines in a frame is shared before and after them: where the +/// alignment leaves room, so the text doesn't move or rewrap. +fn slack_sides(justify: Justify, rtl: bool, slack: f64) -> (f64, f64) { + match justify { + Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), + Justify::Right | Justify::JustifyRight => (slack, 0.0), + Justify::Auto if rtl => (slack, 0.0), + _ => (0.0, slack), + } +} + +/// Point type `t` turned into area type with Dynamic Text's Block on, with the default Area Type +/// Options but its gaps: the frame takes the room along the lines that keeps the widest line as +/// typed at its size ([`needed_room`]), placed so that line stays where it is by its own +/// alignment, and after the fit it takes the depth that holds every line, so nothing overflows. +/// Lines run in line space (for vertical type, down the page), and the frame is mapped to the +/// type's own space by the layout's `line_xf`. `wrap` is the Text Wrap shapes the new area type +/// takes, in the type's own space, which the fit and the depth follow. When negative indents take +/// more than the widest line's width, the frame is as wide as that line and placed so that the +/// line keeps its aligned point. None when no line has a +/// width (empty text, or only spaces and breaks), which Block leaves at its size. +fn point_to_block(t: &TextObject, wrap: &[vectorcraft_doc::WrapShape]) -> Option<TextObject> { + let db = vectorcraft_text::FontDb::global(); + // Options point type kept from a frame it had before (columns, inset, alignment, first + // baseline) do not carry over to the new frame. + let mut t = t.clone(); + let (word_gap, line_gap) = (t.area.word_gap, t.area.line_gap); + t.area = vectorcraft_doc::AreaOptions { word_gap, line_gap, ..Default::default() }; + t.wrap = wrap.to_vec(); + let t = &t; + // The room is measured as Block sets the lines in a frame, with the word gap. + let as_block = TextObject { dynamic_text: true, kind: TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)) }, ..t.clone() }; + let (room, widest, para_index) = needed_room(&as_block)?; + let lay = vectorcraft_text::layout(db, t); + let first = lay.lines.first()?; + let line = lay.lines.iter().find(|l| l.start == widest)?; + // Across, the widest line as it is aligned, with its paragraph's indents, which the frame holds. + let para = t.para_at(para_index); + let starts = widest == 0 || t.plain_text().as_bytes().get(widest - 1) == Some(&b'\n'); + let start_indent = para.laid_out_left_indent() + if starts { para.first_line_indent } else { 0.0 }; + // A horizontal right-to-left list paragraph has its indents mirrored, as the layout sets them. + let mirror = line.rtl && !t.vertical && para.list.is_some(); + let (left, right) = if mirror { (para.right_indent, start_indent) } else { (start_indent, para.right_indent) }; + let (lo, hi) = (line.x0 - left, line.x1 - line.trailing + right); + let top = lay.lines.iter().map(|l| l.baseline - l.ascent).fold(first.baseline - first.ascent, f64::min); + let bottom = lay.lines.iter().map(|l| l.baseline + l.descent).fold(top, f64::max); + if ![lo, hi, top, bottom, room].iter().all(|v| v.is_finite()) { + return None; + } + // Negative indents can take more than the line's width (hi below lo); the frame is then wider + // than the line with its indents, and the same split of the difference keeps the line's + // aligned point (its start, center or end) where it was when Block makes the line fill it. + let (sl, _) = slack_sides(para.justify, line.rtl, room - (hi - lo)); + let frame = |depth: f64| lay.line_xf.transform_rect_bbox(Rect::new(lo - sl, top, lo - sl + room, top + depth.max(1.0))); + let start = frame(bottom - top); + let mut out = t.clone(); + out.kind = TextKind::Area { frame: shapes::rectangle(start) }; + out.dynamic_text = true; + refresh_bounds(&mut out); + // The lines fitted in one cell from the frame's top deep enough for all of them (every line at + // the largest size Block sets), around any wrap shapes, give the depth the frame takes. + let mut probe = out.clone(); + let points = t.style_scale().map_or(1.0, |s| s.points).max(1e-12); + let deep = (bottom - top) + (lay.lines.len() as f64 + 1.0) * 3.0 * (1296.0 + out.area.line_gap.abs()) / points; + // Wrap shapes push lines on past them (an inverted one holds them inside it), so the cell also + // reaches as far again as the farthest one reaches past the frame's top. + let inv = lay.line_xf.inverse(); + let deep = deep + + wrap + .iter() + .filter_map(|w| w.path.bounds().map(|r| inv.transform_rect_bbox(r)).map(|r| (r.y1 - top).max(0.0) + 2.0 * w.wrap.offset.abs())) + .filter(|d| d.is_finite()) + .fold(0.0, f64::max); + probe.kind = TextKind::Area { frame: shapes::rectangle(frame(deep)) }; + if !wrap.is_empty() { + refresh_bounds(&mut probe); + } + let laid = vectorcraft_text::layout(db, &probe); + if laid.overflow { + return Some(out); + } + let mut grown = start; + for l in &laid.lines { + for y in [l.baseline - l.ascent, l.baseline + l.descent] { + grown = grown.union_pt(laid.line_xf * vectorcraft_geom::Point::new(l.x0, y)); + } + } + // Only the depth grows (for vertical type, the columns run on to the left), with a hundredth + // of a point to spare. + if t.vertical { + (grown.y0, grown.y1, grown.x0) = (start.y0, start.y1, grown.x0 - 0.01); + } else { + (grown.x0, grown.x1, grown.y1) = (start.x0, start.x1, grown.y1 + 0.01); + } + if grown.is_finite() && grown != start { + out.kind = TextKind::Area { frame: shapes::rectangle(grown) }; + refresh_bounds(&mut out); + } + Some(out) +} + /// (run, byte within the run) for a byte offset into the plain text. fn locate(runs: &[TextRun], byte: usize) -> Option<(usize, usize)> { let mut off = 0; @@ -430,6 +543,136 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> { Ok(ids_json(&ids)) } +/// Whether type on a path `t` runs along one closed path (no open or compound path). +fn on_one_closed_path(t: &TextObject) -> bool { + matches!(&t.kind, TextKind::OnPath { path, .. } if path.subpaths.len() == 1 && path.is_closed()) +} + +/// Why `type.pathCenter.top` and `.bottom` refuse type on a path `ids`: those on an open or +/// compound path, then those in a thread; None when they take every one. +fn path_center_refusal(d: &Document, ids: &[NodeId]) -> Option<String> { + let open = ids.iter().filter(|id| !path_text(d.node(**id)).is_some_and(on_one_closed_path)).map(|id| id.0.to_string()).collect::<Vec<_>>(); + if !open.is_empty() { + let open = open.join(", "); + return Some(format!("type on an open or compound path cannot be centered on its top or bottom: {open}")); + } + let frames = threaded_ids(d, ids); + (!frames.is_empty()).then(|| format!("type on a path in a thread cannot be centered on its top or bottom: {frames}")) +} + +/// `type.pathCenter.top` and `.bottom` are enabled for a selection holding type on a path, all of +/// it on one closed path and none of it in a thread. +fn has_closed_path_type(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document open")?; + let d = &st.doc; + let selected: Vec<NodeId> = st.selection.objects.to_vec(); + let paths: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect(); + if paths.is_empty() { + return Err("select type on a closed path".into()); + } + path_center_refusal(d, &paths).map_or(Ok(()), Err) +} + +/// The brackets that center type on a path `t` on its path's topmost point (`bottom`: its +/// bottommost) as `t.xf` draws it, its span keeping its length; None for a path with no length or +/// no height. +fn centered_brackets(t: &TextObject, bottom: bool) -> Option<(f64, Option<f64>)> { + let TextKind::OnPath { path, end, .. } = &t.kind else { return None }; + let (from, to) = t.kind.path_span()?; + let arc = vectorcraft_geom::ArcPath::new(path); + if arc.is_empty() { + return None; + } + let flip = if bottom { Affine::FLIP_Y } else { Affine::IDENTITY }; + let middle = arc.topmost(flip * t.xf)? / arc.len(); + let run = to - from; + let start = (middle - run * 0.5).rem_euclid(1.0).clamp(0.0, 1.0); + // Without an end bracket the type runs once round; a full turn's end stays on its start. + let end = end.map(|_| if run >= 1.0 - 1e-12 { start } else { (start + run).rem_euclid(1.0).clamp(0.0, 1.0) }); + Some((start, end)) +} + +/// Whether brackets `a` and `b`, fractions of a closed path's length, are at one place within +/// rounding (0 and 1 are one place). +fn same_bracket(a: f64, b: f64) -> bool { + let d = (a - b).rem_euclid(1.0); + d.min(1.0 - d) <= 1e-9 +} + +fn path_center_top(s: &mut Session, p: &Value) -> Result<Value> { + path_center(s, p, false) +} + +fn path_center_bottom(s: &mut Session, p: &Value) -> Result<Value> { + path_center(s, p, true) +} + +/// Type › Type on a Path › Center on Top and Center on Bottom: slide the selected type on closed +/// paths along them so the middle of each span lands on the path's top (`bottom`: its bottom) as +/// drawn, and center the first paragraph, in one undo step; none when nothing changes. +fn path_center(s: &mut Session, p: &Value, bottom: bool) -> Result<Value> { + let c = if bottom { "type.pathCenter.bottom" } else { "type.pathCenter.top" }; + let centered = |j: Justify| matches!(j, Justify::Center | Justify::JustifyCenter); + // Each object the command changes, with its new brackets when they move and whether its first + // paragraph is set to Center. + let (ids, changes) = { + let d = &s.doc()?.doc; + let ids: Vec<NodeId> = texts(s, p, c)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect(); + if ids.is_empty() { + return Err(bad(c, "select type on a closed path")); + } + if let Some(why) = path_center_refusal(d, &ids) { + return Err(bad(c, why)); + } + let (mut changes, mut flat) = (vec![], vec![]); + for id in &ids { + let Some(t) = path_text(d.node(*id)) else { continue }; + let TextKind::OnPath { start, end, .. } = &t.kind else { continue }; + let Some((to_start, to_end)) = centered_brackets(t, bottom) else { + flat.push(id.0.to_string()); + continue; + }; + let stays = same_bracket(*start, to_start) + && match (end, to_end) { + (None, None) => true, + (Some(a), Some(b)) => same_bracket(*a, b), + _ => false, + }; + let align = !centered(t.para_at(0).justify); + if !stays || align { + changes.push((*id, (!stays).then_some((to_start, to_end)), align)); + } + } + if !flat.is_empty() { + let flat = flat.join(", "); + return Err(bad(c, format!("type on a path with no length or no height cannot be centered: {flat}"))); + } + (ids, changes) + }; + if !changes.is_empty() { + s.edit(if bottom { "Center on Bottom" } else { "Center on Top" }, |d, _| { + for (id, brackets, align) in &changes { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + if let (Some(b), TextKind::OnPath { start, end, .. }) = (brackets, &mut t.kind) { + (*start, *end) = *b; + } + if *align { + t.edit_paras(Some(0..1), |para| para.justify = Justify::Center); + } + refresh_bounds(t); + } + Ok(()) + })?; + } + let d = &s.doc()?.doc; + let (start, end) = ids.first().and_then(|id| path_text(d.node(*id))).map_or((0.0, None), |t| match &t.kind { + TextKind::OnPath { start, end, .. } => (*start, *end), + _ => (0.0, None), + }); + Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "start": start, "end": end})) +} + /// The options `type.pathOptions` sets; with none of them given it queries. const PATH_OPTIONS: [&str; 6] = ["start", "end", "flip", "effect", "alignToPath", "spacing"]; @@ -550,14 +793,23 @@ fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> { path_text(n).or_else(|| area_text(n)) } -/// `type.dynamicText` is enabled for a selection holding type on a path or area type, none of it -/// in a thread. +/// Type `type.dynamicText` takes: type on a path, area type, and point type, which it turns into +/// area type with Block on. +fn dynamic_target(n: Option<&Node>) -> Option<&TextObject> { + match n.map(|n| &n.kind) { + Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point) => Some(t), + _ => dynamic_type(n), + } +} + +/// `type.dynamicText` is enabled for a selection holding type on a path, area type or point type, +/// none of it in a thread. fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or("no document open")?; let d = &st.doc; let selected: Vec<NodeId> = st.selection.objects.to_vec(); - let types: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| dynamic_type(d.node(*id)).is_some()).collect(); + let types: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| dynamic_target(d.node(*id)).is_some()).collect(); if types.is_empty() { return Err("select type on a path or area type".into()); } @@ -583,32 +835,46 @@ fn threaded_ids(d: &Document, ids: &[NodeId]) -> String { /// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on /// when a gap is given) on the selected type on a path and area type (Block, with its gaps), in -/// one undo step, and none when nothing changes. Turning it off keeps the sizes the text was drawn -/// at ([`bake_dynamic_text`]). +/// one undo step, and none when nothing changes. Turning it on also turns the selected point type +/// into area type with Block on ([`point_to_block`]). Turning it off keeps the sizes the text was +/// drawn at ([`bake_dynamic_text`]). fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "type.dynamicText"; let (word_gap, line_gap) = block_gaps(p, C)?; - let (ids, on, planned) = { + let (ids, on, planned, converted) = { let d = &s.doc()?.doc; - let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| dynamic_type(d.node(*i)).is_some()).collect(); + let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| dynamic_target(d.node(*i)).is_some()).collect(); let first = *ids.first().ok_or_else(|| bad(C, "select type on a path or area type"))?; if let Some(why) = threaded_refusal(d, &ids) { return Err(bad(C, why)); } let gap_given = word_gap.is_some() || line_gap.is_some(); - if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some()) { + let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; + // Point type with a line to fit, which turning it on makes area type. + let converts = |id: &NodeId| on && dynamic_target(d.node(*id)).is_some_and(|t| matches!(t.kind, TextKind::Point) && needed_room(t).is_some()); + if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some() || converts(id)) { return Err(bad(C, "wordGap and lineGap need area type in the selection")); } - let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; // Each object as the command leaves it, and only those it changes. let mut planned: Vec<(NodeId, TextObject)> = vec![]; + // The point type that becomes area type, with the gaps applied, by id. + let mut converted: Vec<(NodeId, TextObject)> = vec![]; for id in &ids { - let Some(t) = dynamic_type(d.node(*id)) else { continue }; + let Some(t) = dynamic_target(d.node(*id)) else { continue }; let mut after = t.clone(); - if matches!(t.kind, TextKind::Area { .. }) { + if matches!(t.kind, TextKind::Area { .. } | TextKind::Point) { after.area.word_gap = word_gap.unwrap_or(t.area.word_gap); after.area.line_gap = line_gap.unwrap_or(t.area.line_gap); } + if matches!(t.kind, TextKind::Point) { + // Point type has no Dynamic Text of its own: on, it becomes area type with Block + // on; off, or with no line to fit, it stays as it is. + if let Some(block) = on.then(|| point_to_block(&after, &[])).flatten() { + planned.push((*id, block)); + converted.push((*id, after)); + } + continue; + } match (on, t.dynamic_text) { (true, true) => refresh_bounds(&mut after), (true, false) => { @@ -628,7 +894,7 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> { planned.push((*id, after)); } } - (ids, on, planned) + (ids, on, planned, converted) }; if !planned.is_empty() { s.edit("Dynamic Text", |d, _| { @@ -637,6 +903,19 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> { **t = after.clone(); } } + if converted.is_empty() { + return Ok(()); + } + // The new area type takes the Text Wrap shapes above it; one that has any is fitted + // again around them, with the depth that holds every line. + for id in vectorcraft_doc::text::resolve_wrap(None, d) { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + let block = converted.iter().find(|(c, _)| *c == id).and_then(|(_, src)| point_to_block(src, &t.wrap)); + match block { + Some(block) => **t = block, + None => refresh_bounds(t), + } + } Ok(()) })?; } diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs index 9e641a6e6a..d1c66d9d3c 100644 --- a/crates/engine/src/cmd/webexport.rs +++ b/crates/engine/src/cmd/webexport.rs @@ -645,7 +645,7 @@ fn flatten(rgba: &[u8], matte: [u8; 3]) -> impl Iterator<Item = [u8; 3]> + '_ { /// The File Info text `s.metadata` lets into the files: `(keyword, text)`. fn metadata(doc: &Document, s: &WebSettings) -> Vec<(&'static str, String)> { - doc.metadata.png_text(&doc.title).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect() + doc.metadata.png_text(doc.info_title()).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect() } /// Encode straight RGBA pixels (`w`×`h`) as `s` says; `doc` gives the metadata. 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<CommandSpec> { "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/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<cmd::globaledit::GlobalEdit>, + /// 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<Interaction>, @@ -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/error.rs b/crates/engine/src/error.rs index d32fda613d..6ab1608bd7 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -3,6 +3,9 @@ use super::{NodeId, cancel}; /// The text of [`EngineError::Busy`], which ends every error a frontend reports for it. pub const SCRIPT_BUSY: &str = "a script is running: stop it or wait for it to finish"; +/// Stable prefix for a save refused by the filesystem: choose another destination. +pub const READ_ONLY_LOCATION: &str = "READ_ONLY_LOCATION"; + #[derive(Debug, thiserror::Error)] pub enum EngineError { #[error("unknown command `{0}`")] @@ -20,6 +23,10 @@ pub enum EngineError { NoNode(NodeId), #[error("{0}")] Other(String), + /// The save's terminal filesystem error refuses writes here. The reason keeps the path and + /// system message. This never means an automation-root refusal or a recovery-copy failure. + #[error("{code}: {0}", code = READ_ONLY_LOCATION)] + ReadOnlyLocation(String), /// The caller asked for the command to stop ([`cancel`]). It changed nothing. #[error("cancelled; nothing was changed")] Cancelled, diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 9bad62a6c8..58832adf29 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -285,8 +285,8 @@ fn document_base(s: &Session) -> Value { json!({ "uid": st.uid, "title": st.title(), - // The File Info title (`file.info`), where `title` comes from the file name once the document has one. - "fileInfoTitle": d.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, "dirty": st.is_dirty(), "revision": st.revision, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cd3a103a86..9890c9b08c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -382,7 +382,7 @@ pub(crate) use command_sets::SELECTS_GIVEN_IDS; use command_sets::given_ids; pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit}; pub use doc_state::{DocState, OpenRows, RecalledSelection}; -pub use error::{EngineError, Result, SCRIPT_BUSY}; +pub use error::{EngineError, READ_ONLY_LOCATION, Result, SCRIPT_BUSY}; #[cfg(test)] use history::history_budget_for; pub use history::{History, HistoryEntry, NamedStep, default_history_budget}; diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 16df992871..d1265172ae 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -2119,6 +2119,27 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) { host.console.borrow_mut().push(line, cut); } +/// The error of the failed run `report` describes, as its console lines give it; `name` is the +/// run's name in those lines ([`console::line_name`]). +fn error_text(name: &str, report: &Value) -> String { + let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); + let message = report["message"].as_str().unwrap_or_default(); + match report["file"].as_str() { + Some(file) if line > 0 => format!("{name}: {file}:{line}:{column}: {message}"), + _ if line > 0 => format!("{name}:{line}:{column}: {message}"), + _ => format!("{name}: location unavailable: {message}"), + } +} + +/// The console lines of the error of the failed run `report` describes, each as the console keeps +/// it, cut to its limit. +pub(crate) fn error_lines(report: &Value) -> Vec<String> { + let name = console::line_name(report["name"].as_str().unwrap_or_default()); + let mut lines = vec![]; + each_line(&error_text(&name, report), |line, _| lines.push(line)); + lines +} + /// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run /// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report /// line in the session's console. Adds the run's time, host calls by kind, slowest commands, @@ -2133,14 +2154,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console:: } } if report["ok"] == false { - let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); - let message = report["message"].as_str().unwrap_or_default(); - let text = match report["file"].as_str() { - Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name), - _ if line > 0 => format!("{}:{line}:{column}: {message}", host.name), - _ => format!("{}: location unavailable: {message}", host.name), - }; - each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut)); + each_line(&error_text(&host.name, report), |line, cut| line_to(s, host, console::Entry::Error(line), cut)); } let timing = clock().is_some(); let ms = millis(host.run_nanos.get()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7f3999a22e..3363bb4a0c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -206,6 +206,7 @@ mod typescale; mod typeundo; mod units; mod variables; +mod warp_images; mod webexport; mod widthpoints; mod widthprofiles; diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 243ea564f4..9a3a18ed91 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,10 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); 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"]); + assert_eq!( + ids, + ["art-ribbons", "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"})); assert_eq!( @@ -124,6 +127,12 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] ); assert_eq!(libs[6]["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::<Vec<_>>(), + [("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))); 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]})); @@ -137,6 +146,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ("scatter-shapes", "scatter"), ("game-trails", "art"), ("energy-beams", "art"), + ("dry-media", "art"), ] { let (_, lib) = cmd::brushlib::library(&s, id).unwrap(); assert!(lib.len() >= 4, "{id}"); @@ -166,6 +176,137 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err()); } +/// 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; + for (i, a) in pts.iter().enumerate() { + let b = pts[(i + 1) % pts.len()]; + if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) { + odd = !odd; + } + } + odd +} + +/// Every anchor of the paths in `nodes` and their descendants. +fn anchors(nodes: &[&vectorcraft_doc::Node]) -> Vec<vectorcraft_geom::Point> { + let mut out = vec![]; + for n in nodes { + n.walk(&mut |c| out.extend(c.path_data().into_iter().flat_map(|p| p.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))))); + } + out +} + +/// Dry Media: four art brushes of grainy dry marks (chalk, pencil, crayon, charcoal) with names +/// of their own. Each one's art is a bounded set of filled vector paths 100 pt long that takes the +/// stroke color as tints, with real even-odd gaps in the chalk and the crayon; it draws finite art +/// on curved and short paths, is the same every time it is made, and goes into a document in one +/// undo step, once, applied, and through a saved library unchanged. +#[test] +fn the_dry_media_library_holds_grainy_art_brushes_with_bounded_art() { + use vectorcraft_brush::{ArtScale, Colorization}; + use vectorcraft_geom::FillRule; + let mut s = session(); + let (info, lib) = cmd::brushlib::library(&s, "dry-media").unwrap(); + assert_eq!((info.name.as_str(), info.category, lib.name.as_str()), ("Dry Media", "builtIn", "Dry Media")); + let names: Vec<String> = lib.brushes.iter().map(|b| b.name.clone()).collect(); + assert_eq!(names, ["Chalk Grain", "Graphite Pencil", "Wax Crayon", "Charcoal Dust"]); + let mut total = 0; + for (b, (height, gaps)) in lib.brushes.iter().zip([(8.0, true), (2.5, false), (6.0, true), (7.0, false)]) { + let BrushKind::Art(a) = &b.kind else { panic!("{} is not an art brush", b.name) }; + assert_eq!((a.colorization, a.scale, a.width), (Colorization::Tints, ArtScale::Stretch, 100.0), "{}", b.name); + let (mut paths, mut holes) = (0, 0); + a.art.walk(&mut |n| match &n.kind { + NodeKind::Path { path, .. } => { + paths += 1; + assert!(path.subpaths.iter().all(|sp| sp.closed && sp.anchors.len() >= 4), "{}", b.name); + } + NodeKind::Compound { children, rule } => { + assert_eq!(*rule, FillRule::EvenOdd, "{}", b.name); + // The gaps lie inside the band and apart from each other, so each one is a hole. + let body = anchors(&[&*children[0]]); + let boxes: Vec<vectorcraft_geom::Rect> = children[1..].iter().map(|c| c.path_data().unwrap().bounds().unwrap()).collect(); + for (i, c) in children[1..].iter().enumerate() { + assert!(anchors(&[&**c]).iter().all(|p| inside(*p, &body)), "{}: gap {i} leaves the band", b.name); + let r = boxes[i]; + assert!(boxes[..i].iter().all(|o| r.x0 >= o.x1 || o.x0 >= r.x1 || r.y0 >= o.y1 || o.y0 >= r.y1), "{}: gap {i} overlaps", b.name); + } + holes += children.len() - 1; + } + NodeKind::Group { .. } => {} + other => panic!("{}: unexpected {other:?}", b.name), + }); + let pts = anchors(&[&a.art]); + assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + assert!(paths <= 80 && pts.len() <= 500, "{}: {paths} paths, {} anchors", b.name, pts.len()); + assert_eq!(holes > 0, gaps, "{}: {holes} gaps", b.name); + let r = vectorcraft_geom::Rect::from_points(pts[0], pts[0]); + let r = pts.iter().fold(r, |r, p| r.union_pt(*p)); + assert!(r.x0 >= 0.0 && r.x0 < 0.5 && r.x1 <= 100.0 && r.x1 > 99.5, "{}: {r:?}", b.name); + assert!((r.height() - height).abs() <= height * 0.25 && r.y0 < 0.0 && r.y1 > 0.0, "{}: {r:?}", b.name); + total += pts.len(); + // Finite art on a curve and on a 2 pt path. + for (line, width) in [(vec![(0.0, 0.0), (60.0, -40.0), (140.0, 40.0), (200.0, 0.0)], 1.0), (vec![(0.0, 0.0), (2.0, 0.0)], 3.0)] { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to(line[0]); + if line.len() == 4 { + bp.curve_to(line[1], line[2], line[3]); + } else { + bp.line_to(line[1]); + } + let stroke = vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), width); + let pieces = vectorcraft_brush::stroke_pieces(&lib.doc, b, &bp, &stroke); + let drawn = anchors(&pieces.iter().collect::<Vec<_>>()); + assert!(!drawn.is_empty() && drawn.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{} on {line:?}", b.name); + } + } + assert!(total <= 1200, "{total} anchors"); + // Made again, the same bytes; the checksum pins them across runs and platforms. + let bytes = serde_json::to_string(&cmd::brushlib::dry_media().brushes).unwrap(); + assert_eq!(bytes, serde_json::to_string(&lib.brushes).unwrap()); + let fnv = bytes.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)); + assert_eq!(fnv, 12_859_086_488_513_909_464, "the Dry Media art changed"); + // Names of their own: no default brush's, no other built-in library's. + let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect(); + let others: Vec<String> = cmd::brushlib::BUILTIN + .iter() + .filter(|b| b.id != "dry-media") + .flat_map(|b| cmd::brushlib::library(&s, b.id).unwrap().1.brushes.iter().map(|x| x.name.clone()).collect::<Vec<_>>()) + .collect(); + assert!(names.iter().all(|n| !defaults.contains(&n.as_str()) && !others.contains(n)), "{names:?}"); + + // Added and applied to a path in one undo step, which undo takes back. + let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap(); + let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone()); + let before = (undo_len(&s), brushes(&s)); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Dry Media", "name": "Wax Crayon", "apply": true, "ids": [path]})); + assert_eq!((strs(&r["added"]), r["applied"].clone()), (vec!["Wax Crayon"], json!([path]))); + assert_eq!((undo_len(&s), stroke_brush(&s).as_deref()), (before.0 + 1, Some("Wax Crayon"))); + assert_eq!(&brush(&s, "Wax Crayon"), lib.brush("Wax Crayon").unwrap()); + run(&mut s, "edit.undo", json!({})); + assert_eq!((brushes(&s), stroke_brush(&s)), (before.1.clone(), None)); + run(&mut s, "edit.redo", json!({})); + // The rest in one more step; then nothing new to add. + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "dry-media"})); + assert_eq!((strs(&r["added"]), strs(&r["existing"])), (vec!["Chalk Grain", "Graphite Pencil", "Charcoal Dust"], vec!["Wax Crayon"])); + assert_eq!(undo_len(&s), before.0 + 2); + assert_eq!( + brushes(&s), + [before.1.clone(), vec!["Wax Crayon".into(), "Chalk Grain".into(), "Graphite Pencil".into(), "Charcoal Dust".into()]].concat() + ); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "dry-media"})); + assert_eq!((strs(&r["added"]).len(), undo_len(&s)), (0, before.0 + 2)); + // Saved as a library and loaded back: the same brushes, in the document's order. + let saved = run(&mut s, "brush.saveLibrary", json!({ "names": names, "name": "Dry" })); + let b64 = saved["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Dry.vectorcraft"})); + let (_, reopened) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert_eq!(reopened.len(), lib.len()); + for b in &lib.brushes { + assert_eq!(reopened.brush(&b.name), Some(b), "{}", b.name); + } +} + /// Bristle Brushes: one brush per tip shape, every value inside the range the brush keeps (a value /// outside would be clamped without notice), names no other library or default brush uses; added /// in one step, not twice, and saved as a library they reopen unchanged. diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index 20cad1dc01..8605a7e052 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -88,19 +88,17 @@ fn dynamic_text_refuses_bad_params_and_other_type() { assert!(err(s.execute("type.dynamicText", &json!({"on": 1}))).contains("on")); assert_eq!(undo_len(&s), before); assert!(!text(&s, id).dynamic_text); - // Point type alone: refused (area type takes Block, `typecmd::block_tests`). let point = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [point]})).unwrap(); - assert!(err(s.execute("type.dynamicText", &json!({}))).contains("select type on a path or area type")); let empty = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [empty]})).unwrap(); assert!(s.execute("type.dynamicText", &json!({})).is_err()); - // With point type selected too, only the type on a path changes. + // With point type selected too, both change in one undo step: the point type becomes area + // type with Block on (`typecmd::block_tests`). let before = undo_len(&s); s.execute("select.set", &json!({"ids": [point, id.0]})).unwrap(); let r = s.execute("type.dynamicText", &json!({"on": true})).unwrap(); - assert_eq!(r["ids"], json!([id.0])); - assert!(!text(&s, NodeId(point)).dynamic_text); + assert_eq!(r["ids"], json!([point, id.0])); + assert!(text(&s, NodeId(point)).dynamic_text && text(&s, id).dynamic_text); assert_eq!(undo_len(&s), before + 1); } diff --git a/crates/engine/src/tests/effectedit.rs b/crates/engine/src/tests/effectedit.rs index 2d10c3f5d9..34e01bf31e 100644 --- a/crates/engine/src/tests/effectedit.rs +++ b/crates/engine/src/tests/effectedit.rs @@ -147,3 +147,37 @@ fn show_all_hidden_attributes() { s.execute("edit.undo", &json!({})).unwrap(); assert!(node(&s, id).appearance.has_hidden()); } + +/// A Warp applied now takes its style's Bézier net (`version` 1) unless a map is given, an edit +/// keeps the map the effect has (one saved before the version existed keeps the earlier map), and +/// a map this build doesn't draw is refused. +#[test] +fn warps_keep_the_map_their_version_names() { + let (mut s, id) = session_with_rect(); + let version = |s: &Session| node(s, id).appearance.effects[0].params.get("version").cloned(); + run(&mut s, "effect.apply", json!({"effect": "warp.flag", "params": {"bend": 40}})); + assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET))); + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 20}, "warpStyle": "arc"})); + assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET))); + // As a file saved before the version existed gives it. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(Value::Object(p)) = doc.node_mut(id).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) { + p.remove("version"); + } + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 10, "horizontal": 20}})); + assert_eq!(version(&s), None); + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"version": 1}})); + assert_eq!(version(&s), Some(json!(1))); + run(&mut s, "effect.apply", json!({"effect": "warp.arc", "params": {"version": 0}})); + assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0)); + // A whole number given as a float is that version. + run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 1.0}})); + assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(1)); + run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 0.0}})); + assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0)); + for bad in [json!(2), json!(-1), json!(0.5), json!("1")] { + assert!(s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"version": bad}})).is_err(), "{bad}"); + assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"version": bad}})).is_err(), "{bad}"); + } + assert_eq!(node(&s, id).appearance.effects.len(), 2); +} diff --git a/crates/engine/src/tests/envelope.rs b/crates/engine/src/tests/envelope.rs index e4d030d387..4ffd7a361a 100644 --- a/crates/engine/src/tests/envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -123,6 +123,51 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() { assert!(s.execute("object.envelope.resetWithMesh", &json!({})).is_err()); } +/// A new warp envelope bends with its style's Bézier net (`version` [`live::WARP_NET`]) and keeps +/// it through Reset with Warp. One saved before the version existed keeps the earlier map when its +/// warp or its options change. +#[test] +fn new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map() { + // The surface point a quarter of the way along the top edge. + let quarter = |s: &Session, e| live::EnvelopeMap::of(&node(s, e)).unwrap().surface_mesh(4, 4, vectorcraft_color::Color::BLACK).points[1].p; + let at = |x, y| vectorcraft_geom::Point::new(x, y); + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40})); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e)); + assert_eq!(s.execute("object.envelope.info", &json!({})).unwrap()["version"], json!(live::WARP_NET)); + // Flag moves the top edge 6 × bend × height × u(1 - u)(1 - 2u) down at u: 22.5 at u = 1/4. + assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e)); + s.execute("object.envelope.resetWithWarp", &json!({"bend": -40})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e)); + assert!(quarter(&s, e).distance(at(150.0, 77.5)) < 1e-9, "{:?}", quarter(&s, e)); + // As a file saved before the version existed gives it. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(NodeKind::Envelope { kind, .. }) = doc.node_mut(e).map(|n| &mut n.kind) + && let EnvelopeKind::Warp { version, .. } = &mut **kind + { + *version = 0; + } + s.execute("object.envelope.options", &json!({"fidelity": 75})).unwrap(); + s.execute("object.envelope.resetWithWarp", &json!({"bend": 40})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e)); + // The earlier map's flag: half the bend times sin(π x), x = -1/2 here, in half heights. + assert!(quarter(&s, e).distance(at(150.0, 90.0)) < 1e-9, "{:?}", quarter(&s, e)); + // `version` moves a warp to a map, and a new warp can take the earlier one. + s.execute("object.envelope.options", &json!({"version": 1.0})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e)); + assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e)); + s.execute("object.envelope.resetWithWarp", &json!({"version": 0})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e)); + let (_, old) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40, "version": 0})); + assert!(matches!(kind(&s, old), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, old)); + for bad in [json!(2), json!(-1), json!(0.5), json!("1"), json!(null)] { + for cmd in ["object.envelope.makeWithWarp", "object.envelope.resetWithWarp", "object.envelope.options"] { + let r = s.execute(cmd, &json!({"version": bad})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("`version` must be 0 or 1")), "{cmd} {bad}: {r:?}"); + } + } +} + #[test] fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { let mut s = session(); @@ -141,9 +186,9 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { let content = node(&s, a); assert_eq!((content.opacity, content.blend, content.extras().mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look"); let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") }; - // The mesh follows the arc: its outline reaches below the content's bottom edge at the sides. + // The mesh follows the arc: its top corners swing out past the content's sides and up. let b = m.outline().bounds().unwrap(); - assert!(b.y1 > 201.0, "{b:?}"); + assert!(b.x0 < 99.0 && b.x1 > 301.0 && b.y0 < 99.0, "{b:?}"); assert_eq!(s.doc().unwrap().selection.objects, ids); } diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index 8a1e46c68a..a0193a27f8 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -154,6 +154,35 @@ fn images_bend_as_a_raster_mesh_warp() { assert_eq!(svg.matches("<image").count(), 1, "the pieces share the image's pixels"); } +#[test] +fn a_colorized_image_keeps_its_color_in_an_envelope() { + let mut s = session(); + let img = image::GrayImage::from_pixel(4, 4, image::Luma([0])); + let mut png = vec![]; + image::DynamicImage::ImageLuma8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let im = s + .edit("Place", |d, sel| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0); + let im = + vectorcraft_doc::ImageObject { key: "gray".into(), width: 4, height: 4, xf, 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!({"color": "#ff0000", "ids": [im.0]})).unwrap(); + sel(&mut s, &[im]); + arch(&mut s, 80.0, json!({})); + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + // The black pixels draw in the fill color, in the bent shape. + let mid = img.pixel(120, 97); + assert!(mid[0] > 200 && mid[1] < 60 && mid[2] < 60, "the middle rose, red: {mid:?}"); + let inside = img.pixel(120, 120); + assert!(inside[0] > 200 && inside[1] < 60 && inside[2] < 60, "red inside: {inside:?}"); +} + /// A selected embedded 2×2 image, 80×80 pt at (100, 100): a red column left of a blue one. fn two_color_image(s: &mut Session) -> NodeId { let img = image::RgbaImage::from_fn(2, 2, |x, _| if x == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) }); @@ -286,17 +315,18 @@ fn symbols_bend_with_the_envelope() { let e = arch(&mut s, 60.0, json!({})); let pts = anchors(&evaluated(&s, e)); assert!(pts.iter().any(|p| p.y < 90.0), "the symbol's art rose in the middle"); - // Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art). + // Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art). The + // arch lifts the middle of the 100..200 box by about 51, so (200, 120) is inside it. let mut r = vectorcraft_render::Renderer::new(); let region = Rect::new(0.0, 0.0, 400.0, 300.0); - assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] < 60); + assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] < 60); s.edit("edit symbol", |d, _| { let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(-10.0, -10.0, -8.0, -8.0)), Default::default()); d.symbols[0].art = Arc::new(art); Ok(()) }) .unwrap(); - assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] > 200); + assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] > 200); } fn strokes(nodes: &[Node]) -> usize { @@ -601,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/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs index c37cbfe90d..4d86a84b3c 100644 --- a/crates/engine/src/tests/fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -161,7 +161,8 @@ fn an_exported_pdf_opens_with_its_file_info() { assert_eq!(back["description"], "A poster\nfor the fair"); assert_eq!(back["keywords"], json!(["fair", "Poster", "print"])); assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]); - assert_eq!(back["title"], "poster.pdf", "the title stays the file name"); + assert_eq!(back["title"], "Poster", "File Info shows the title the PDF carries"); + assert_eq!(s.doc().unwrap().title(), "poster.pdf", "the document stays named after the file"); assert_eq!(back["copyrightStatus"], "copyrighted"); assert_eq!(back["copyrightNotice"], "(c) 2026 Ada"); assert_eq!(back["copyrightUrl"], "https://example.com/rights"); @@ -277,6 +278,192 @@ fn a_pdf_compatible_ai_opens_with_the_file_info_of_its_xmp_packet() { assert_eq!(m.copyright_url, ""); } +/// A one-page PDF whose trailer names an information dictionary with the entries `info`, and whose +/// catalog has a metadata stream holding an XMP `dc:title` of `xmp_title`. +fn pdf_titled(info: Option<&str>, xmp_title: Option<&str>) -> Vec<u8> { + use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog}; + let first = first_extra(1); + let mut objects = vec![]; + let mut catalog = String::new(); + if let Some(t) = xmp_title { + let xmp = format!( + "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\ + <rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\ + <dc:title><rdf:Alt><rdf:li xml:lang=\"de\">Zugangswege</rdf:li><rdf:li xml:lang=\"x-default\">{t}</rdf:li></rdf:Alt></dc:title>\ + </rdf:Description></rdf:RDF></x:xmpmeta>" + ); + objects.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len())); + catalog = format!("/Metadata {first} 0 R "); + } + let info_at = first + objects.len(); + if let Some(i) = info { + objects.push(format!("<< {i} >>")); + } + let refs: Vec<&str> = objects.iter().map(String::as_str).collect(); + let bytes = pdf_with_catalog(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &refs, &catalog, None); + if info.is_none() { + return bytes; + } + // The trailer follows the cross-reference table, so a longer trailer moves no object. + String::from_utf8(bytes).unwrap().replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {info_at} 0 R"), 1).into_bytes() +} + +/// A document called `name` with content `bytes` opened (as `document.open` with no path: the tab +/// shows the document's name), `s` holding it. +fn open_named(s: &mut Session, name: &str, bytes: &[u8]) -> Value { + s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap() +} + +/// The document's name (its tab) and its File Info title. +fn titles(s: &mut Session) -> [String; 2] { + let info = s.execute("file.info", &json!({})).unwrap(); + [s.doc().unwrap().title(), info["title"].as_str().unwrap().to_string()] +} + +/// What opening `bytes` as a file called `name` gives: the document's name and its File Info title. +fn opened(name: &str, bytes: &[u8]) -> [String; 2] { + let mut s = Session::new(); + let r = open_named(&mut s, name, bytes); + let t = titles(&mut s); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some(t[0].as_str()), Some(t[1].as_str())), "the open reply"); + assert_eq!(s.doc().unwrap().doc.title, t[0], "the tab is the document's title"); + t +} + +fn svg_titled(inner: &str) -> String { + format!( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">{inner}<rect width=\"5\" height=\"5\"><title>Tooltip" + ) +} + +#[test] +fn import_puts_the_files_own_title_in_file_info_only() { + // SVG: the root's ``, its white space made single spaces (not a shape's tooltip), is the + // File Info title; the document keeps the file's name, with its extension. + assert_eq!(opened("box.svg", svg_titled("<title>\n Access\n paths ").as_bytes()), ["box.svg", "Access paths"]); + assert_eq!( + opened("/work/box.svg", svg_titled("Zugangswege Rechenzentrum – Prüfdiagramm").as_bytes()), + ["box.svg", "Zugangswege Rechenzentrum – Prüfdiagramm"] + ); + // PDF: the XMP `dc:title` (its `x-default` item) beats the Info `Title`; the Info `Title` stands alone. + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), ["plan.pdf", "XMP title"]); + assert_eq!(opened("plan.pdf", &pdf_titled(None, Some("XMP title"))), ["plan.pdf", "XMP title"]); + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.pdf", "Info title"]); + assert_eq!(opened("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.ai", "Info title"]); + // Compressed SVG too. + let svgz = |inner: &str| vectorcraft_svg::compress(&svg_titled(inner)); + assert_eq!(opened("box.svgz", &svgz("Access paths")), ["box.svgz", "Access paths"]); + // A file that gives none (or a blank one) gives File Info the document's name. + assert_eq!(opened("box.svg", svg_titled("").as_bytes()), ["box.svg", "box.svg"]); + assert_eq!(opened("box.svg", svg_titled(" ").as_bytes()), ["box.svg", "box.svg"]); + assert_eq!(opened("plan.pdf", &pdf_titled(None, None)), ["plan.pdf", "plan.pdf"]); + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), ["plan.pdf", "plan.pdf"]); + // The Untitled-N name New gives is no title (a document that never had one writes it). + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), ["plan.pdf", "plan.pdf"]); + // A template opens as a new untitled document, whatever title its file gave. + let [name, info] = opened("plan.ait", &pdf_titled(Some("/Title (Info title)"), None)); + assert!(name.starts_with("Untitled-") && info == name, "{name} / {info}"); +} + +#[test] +fn imported_svg_titles_are_filtered_and_bounded() { + for (raw, expected) in [ + ("Untitled-2".to_string(), None), + ("\u{7f}".to_string(), None), + ("Re\u{7f}lease Plan".to_string(), Some("Release Plan".to_string())), + ("λ".repeat(65_537), Some("λ".repeat(65_536))), + ] { + let svg = svg_titled(&format!("{raw}")); + for format in ["svg", "svgz"] { + let name = format!("box.{format}"); + let bytes = if format == "svgz" { vectorcraft_svg::compress(&svg) } else { svg.as_bytes().to_vec() }; + assert_eq!(opened(&name, &bytes), [name.clone(), expected.clone().unwrap_or(name)], "{format}"); + } + } +} + +#[test] +fn a_file_with_a_title_opened_from_a_path_is_named_after_the_file() { + let dir = std::env::temp_dir().join(format!("vc-infotitle-open-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("box.svg"); + std::fs::write(&path, svg_titled("Access paths")).unwrap(); + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths"))); + let r = s.execute("document.inspect", &json!({"depth": 0})).unwrap(); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths"))); + // The default names of what the document exports are made from the file's name, not its title. + assert_eq!(s.doc().unwrap().doc.title, "box.svg"); + assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "png"), "box.png"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn file_info_edits_a_files_own_title_and_the_document_follows() { + let mut s = Session::new(); + open_named(&mut s, "box.svg", svg_titled("Access paths").as_bytes()); + // The dialog sends back the title it was given: nothing changes (no undo step). + let before = s.doc().unwrap().revision; + let shown = s.execute("file.info", &json!({})).unwrap(); + s.execute("file.info", &shown).unwrap(); + assert_eq!(s.doc().unwrap().revision, before, "the title it gave is no edit"); + assert_eq!(titles(&mut s), ["box.svg", "Access paths"]); + // Another title is the document's own, as ever, and the file's is gone. + let r = s.execute("file.info", &json!({"title": "Plan B"})).unwrap(); + assert_eq!(r["title"], "Plan B"); + assert_eq!(titles(&mut s), ["Plan B", "Plan B"]); + assert_eq!(s.doc().unwrap().doc.metadata.title, None); + // Undo brings the file's title back, and the name. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(titles(&mut s), ["box.svg", "Access paths"]); +} + +#[test] +fn restored_file_info_title_keeps_authored_spaces() { + let mut s = session(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).metadata.title = Some("Release Plan".into()); + for format in ["vectorcraft", "svg", "pdf"] { + let bytes = b64(&s.execute("document.export", &json!({"format": format, "preserveEditing": true})).unwrap()); + let mut reopened = Session::new(); + let reply = open_named(&mut reopened, &format!("again.{format}"), &bytes); + assert_eq!(reply["restored"], format != "vectorcraft", "{format}"); + assert_eq!(reopened.doc().unwrap().doc.metadata.title.as_deref(), Some("Release Plan"), "{format}"); + assert_eq!(titles(&mut reopened)[1], "Release Plan", "{format}"); + } +} + +#[test] +fn the_files_own_title_is_kept_by_saves_and_written_by_exports() { + let mut s = Session::new(); + open_named(&mut s, "box.svg", svg_titled("Access paths").as_bytes()); + // The native file: its own title is the document's name, the File Info title survives. + let native = b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()); + let mut t = Session::new(); + open_named(&mut t, "other.vectorcraft", &native); + assert_eq!(titles(&mut t), ["box.svg", "Access paths"]); + // An SVG with the editing data it carries, reopened under another name. + let svg = s.execute("document.serialize", &json!({"format": "svg", "preserveEditing": true, "metadata": true})).unwrap()["text"] + .as_str() + .unwrap() + .to_string(); + assert!(svg.contains("Access paths") && svg.contains("Access paths"), "{svg}"); + let mut u = Session::new(); + let r = open_named(&mut u, "again.svg", svg.as_bytes()); + assert_eq!(r["restored"], true); + assert_eq!(titles(&mut u), ["again.svg", "Access paths"]); + // PDF: the Info `Title` and the XMP `dc:title`, shown by the viewer. + for pdf in pdf_texts(&mut s) { + assert!(pdf.contains("/Title(Access paths)") && xmp_title(&pdf).contains("Access paths"), "{}", xmp_title(&pdf)); + assert!(pdf.contains("/ViewerPreferences<>"), "{pdf}"); + } + // PNG text. + let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap()); + assert!(crate::cmd::fileio::pngtext::text(&png).iter().any(|(k, v)| k == "Title" && v == "Access paths")); + // The default file names are made from the document's name. + assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "pdf"), "box.pdf"); +} + #[test] fn png_exports_carry_text_chunks() { let mut s = session(); diff --git a/crates/engine/src/tests/fontfiles.rs b/crates/engine/src/tests/fontfiles.rs index f31df35eb1..8bef8733b5 100644 --- a/crates/engine/src/tests/fontfiles.rs +++ b/crates/engine/src/tests/fontfiles.rs @@ -194,6 +194,43 @@ fn a_search_finds_a_missing_fonts_file_by_its_names() { assert!(matches!(r["state"].as_str(), Some("stopped" | "done")), "{r}"); } +/// A caller confined by read roots sees, in the replies to a search another caller started, only +/// the files found inside its read roots, and the folder searched and a failed search's path only +/// when they are inside them. +#[test] +fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { + let root = folder("read-roots"); + let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3"); + let (home, wide) = (root.join("home"), root.join("lrf-wide")); + for dir in [&home, &wide] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("Findme.otf"), &font).unwrap(); + } + let mut s = session_for(&root); + let roots = |read: &[&Path]| crate::file_access::AutomationRoots::new(read, &[]).unwrap().unwrap(); + let (narrow, broad) = (roots(&[&home]), roots(&[&root])); + let ask = |s: &mut Session, r: &Arc, p: Value| { + crate::file_access::confine(Some(r), || s.execute("text.findFontFiles", &p).unwrap()) + }; + // A font no file has keeps the search going through every folder. + let fonts = json!([{"family": "Findme Sans 3"}, {"family": "No Such Sans"}]); + ask(&mut s, &broad, json!({"folder": root, "fonts": fonts})); + finished(&mut s); + let all = ask(&mut s, &broad, json!({})); + assert_eq!((&all["fonts"][0]["files"], &all["folder"]), (&json!([home.join("Findme.otf"), wide.join("Findme.otf")]), &json!(root)), "{all}"); + for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] { + assert!(!r.to_string().contains("lrf-wide") && r.get("folder").is_none(), "{r}"); + assert_eq!((&r["fonts"][0]["files"], &r["fonts"][0]["family"]), (&json!([home.join("Findme.otf")]), &json!("Findme Sans 3")), "{r}"); + assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}"); + } + // A search that failed: the reason shows without the path to a caller outside its folder. + ask(&mut s, &broad, json!({"folder": wide.join("gone"), "fonts": fonts})); + assert!(finished(&mut s)["error"].as_str().is_some_and(|e| e.contains("lrf-wide"))); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((r["state"].as_str(), r["error"].as_str(), r.get("folder")), (Some("failed"), Some("the folder can't be read"), None), "{r}"); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn junk_search_params_never_fail_internally() { let root = folder("junk"); 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/linkfiles.rs b/crates/engine/src/tests/linkfiles.rs index 695ea41419..ef9127042a 100644 --- a/crates/engine/src/tests/linkfiles.rs +++ b/crates/engine/src/tests/linkfiles.rs @@ -271,7 +271,7 @@ fn stop_ends_only_the_search_it_names() { let (tx, rx) = mpsc::channel(); let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 2, Arc::new(Blocked(Mutex::new(rx)))).unwrap(); let links = vec![Sought { name: "a.png".into(), path: "/gone/a.png".into(), ids: vec![], size: None, hash: None }]; - s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), links, sizes: Arc::default(), search }); + s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), doc: None, links, sizes: Arc::default(), search }); let until = Instant::now() + WAIT; while s.execute("links.findFiles", &json!({})).unwrap()["searched"]["read"] != 1 && Instant::now() < until { std::thread::sleep(Duration::from_millis(2)); @@ -284,6 +284,116 @@ fn stop_ends_only_the_search_it_names() { drop(tx); } +/// A caller confined by read roots sees, in the replies to a search another caller started and to +/// its own, only the paths inside its read roots: the files found outside them, a link's saved path +/// outside them and the folder searched are left out, and the rows, ids and counts stay. Without +/// roots, the replies are as before. +#[test] +fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { + let root = folder("read-roots"); + let mut s = session_for(&root); + let photo = png(40, 30, RED); + write(&root.join("lrf-away/photo.png"), &photo); + let id = placed(&mut s, &root.join("lrf-away/photo.png")); + std::fs::remove_file(root.join("lrf-away/photo.png")).unwrap(); + // Files of the name with other contents: the search reads every folder. + let other = png(40, 30, BLUE); + let (home, wide) = (root.join("home"), root.join("lrf-wide")); + write(&home.join("photo.png"), &other); + write(&wide.join("photo.png"), &other); + let roots = |read: &[&Path], write: &[&Path]| crate::file_access::AutomationRoots::new(read, write).unwrap().unwrap(); + let (narrow, write_only, broad) = (roots(&[&home], &[]), roots(&[], &[&root]), roots(&[&root], &[])); + let ask = |s: &mut Session, r: &Arc, p: Value| { + crate::file_access::confine(Some(r), || s.execute("links.findFiles", &p).unwrap()) + }; + let hidden = |r: &Value| { + let text = r.to_string(); + assert!(!text.contains("lrf-away") && !text.contains("lrf-wide") && r.get("folder").is_none(), "{r}"); + let row = &r["links"][0]; + assert_eq!((row["name"].as_str(), &row["ids"], row.get("path")), (Some("photo.png"), &json!([id]), None), "{r}"); + }; + // Started with the broad roots, over the whole folder. + let r = ask(&mut s, &broad, json!({"folder": root})); + assert_eq!(r["folder"], json!(root), "{r}"); + finished(&mut s); + let all = ask(&mut s, &broad, json!({})); + assert_eq!(all["links"][0]["files"].as_array().map(Vec::len), Some(2), "{all}"); + for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] { + hidden(&r); + assert_eq!(r["links"][0]["files"], json!([{"path": home.join("photo.png"), "size": other.len(), "match": "name"}]), "{r}"); + assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}"); + } + let r = ask(&mut s, &write_only, json!({})); + hidden(&r); + assert_eq!(r["links"][0]["files"], json!([]), "{r}"); + // Without roots, every path shows. + let r = s.execute("links.findFiles", &json!({})).unwrap(); + assert_eq!((&r["folder"], &r["links"][0]["path"]), (&json!(root), &json!(root.join("lrf-away/photo.png"))), "{r}"); + assert_eq!(r["links"][0]["files"].as_array().map(Vec::len), Some(2), "{r}"); + // The narrow caller's own search shows the folder it gave. + let r = ask(&mut s, &narrow, json!({"folder": home})); + assert_eq!(r["folder"], json!(home), "{r}"); + finished(&mut s); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((&r["folder"], r["links"][0].get("path")), (&json!(home), None), "{r}"); + // A search that failed: the reason shows without the path to a caller outside its folder. + ask(&mut s, &broad, json!({"folder": root.join("lrf-wide/gone")})); + let r = finished(&mut s); + assert!(r["error"].as_str().is_some_and(|e| e.contains("lrf-wide")), "{r}"); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((r["state"].as_str(), r["error"].as_str()), (Some("failed"), Some("the folder can't be read")), "{r}"); + hidden(&r); + let _ = std::fs::remove_dir_all(&root); +} + +/// A link's saved path relative to the document is shown to a confined caller when it leads to a +/// file inside its read roots, and left out when `..` leads out of them. +#[test] +fn a_relative_link_path_is_judged_from_the_documents_folder() { + let root = folder("read-roots-relative"); + let mut s = session_for(&root); + let home = root.join("home"); + write(&root.join("lrf-away/photo.png"), &png(40, 30, RED)); + let id = placed(&mut s, &root.join("lrf-away/photo.png")); + std::fs::create_dir_all(&home).unwrap(); + s.execute("document.save", &json!({"path": home.join("poster.vectorcraft")})).unwrap(); + let narrow = crate::file_access::AutomationRoots::new(&[&home], &[]).unwrap().unwrap(); + for (saved, shown) in [("sub/photo.png", true), ("../lrf-away/photo.png", false)] { + s.edit("relative path", |d, _| { + if let NodeKind::Image(im) = &mut d.node_mut(NodeId(id)).unwrap().kind { + im.link.as_mut().unwrap().path = saved.into(); + } + Ok(()) + }) + .unwrap(); + let r = crate::file_access::confine(Some(&narrow), || s.execute("links.findFiles", &json!({"folder": home, "ids": [id]})).unwrap()); + assert_eq!(r["links"][0].get("path"), shown.then(|| json!(saved)).as_ref(), "{saved}: {r}"); + finished(&mut s); + } + let _ = std::fs::remove_dir_all(&root); +} + +/// `image.info` leaves a linked file outside the confined caller's read roots unnamed (`link` +/// null), as `links.info` leaves it out, and keeps the other fields. +#[test] +fn image_info_leaves_out_a_link_outside_the_read_roots() { + let root = folder("read-roots-info"); + let mut s = session_for(&root); + let pic = root.join("lrf-away/photo.png"); + write(&pic, &png(40, 30, RED)); + let id = placed(&mut s, &pic); + std::fs::create_dir_all(root.join("home")).unwrap(); + let narrow = crate::file_access::AutomationRoots::new(&[&root.join("home")], &[]).unwrap().unwrap(); + let ask = |s: &mut Session, cmd: &str| crate::file_access::confine(Some(&narrow), || s.execute(cmd, &json!({"id": id})).unwrap()); + let r = ask(&mut s, "image.info"); + assert_eq!((&r["link"], &r["linked"], &r["pixelWidth"], &r["id"]), (&Value::Null, &json!(true), &json!(40), &json!(id)), "{r}"); + assert!(!r.to_string().contains("lrf-away"), "{r}"); + let info = ask(&mut s, "links.info"); + assert!(info.get("link").is_none() && !info.to_string().contains("lrf-away"), "{info}"); + assert_eq!(s.execute("image.info", &json!({"id": id})).unwrap()["link"], json!(pic)); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn relinking_with_copy_puts_the_file_in_the_documents_folder() { let root = folder("copy"); diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 0cc01bdae5..f2ab135817 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -570,8 +570,8 @@ fn envelope_warp_make_and_content_stays_inside_bounds() { let pts = all_anchors(&out); assert!(pts.len() > 4, "curves are subdivided"); assert!(pts.iter().all(|p| b.contains(*p)), "{b:?}"); - // It really is bent: the sides moved down. - assert!(pts.iter().any(|p| p.y > 201.0)); + // It really is bent: the top corners swung out past the sides. + assert!(pts.iter().any(|p| p.x < 99.0) && pts.iter().any(|p| p.x > 301.0)); } #[test] diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 4bc58e5f6b..0b573a96f3 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,49 @@ 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); +} + #[test] fn saved_presets_live_in_the_preferences() { let mut s = Session::new(); diff --git a/crates/engine/src/tests/objects_on_path.rs b/crates/engine/src/tests/objects_on_path.rs index 8c0f359df4..4ae8321167 100644 --- a/crates/engine/src/tests/objects_on_path.rs +++ b/crates/engine/src/tests/objects_on_path.rs @@ -531,6 +531,139 @@ fn a_space_that_is_not_usable_is_refused_and_one_too_large_is_kept_to_the_path() assert_eq!(space(&s, g), (Some(100.0), 0.0)); } +/// The ids of group `g`'s objects in their order along the path, and of its children in the +/// Layers panel's order. +fn orders(s: &Session, g: NodeId) -> (Vec, Vec) { + let n = s.doc().unwrap().doc.node(g).unwrap(); + let path = n.extras().objects_on_path.as_deref().unwrap().members.iter().map(|m| m.id).collect(); + (path, n.children().unwrap().iter().map(|c| c.id).collect()) +} + +/// Three objects of different widths (20, 40 and 10 pt) attached to a 300 pt line from (50, 300), +/// in that order, with a Space of 100 pt from 0, Pivot 4 and Rotate 0. +fn three_on_a_line(s: &mut Session) -> (NodeId, [NodeId; 3], NodeId) { + let a = rect(s, 0.0, 0.0, 20.0, 20.0); + let b = rect(s, 100.0, 0.0, 40.0, 20.0); + let c = rect(s, 200.0, 0.0, 10.0, 20.0); + let path = line(s, (50.0, 300.0), (350.0, 300.0)); + let g = attach(s, path, &[a, b, c], json!({})); + s.execute("object.objectsOnPath.options", &json!({"spacing": 100})).unwrap(); + (g, [a, b, c], path) +} + +#[test] +fn move_member_takes_the_target_s_place_and_shifts_the_objects_between() { + let mut s = session(); + let (g, [a, b, c], path) = three_on_a_line(&mut s); + let (_, layers) = orders(&s, g); + let before = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": a.0, "index": 2})); + assert_eq!(orders(&s, g), (vec![b, c, a], layers.clone()), "the order along the path changes, the Layers order does not"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + assert_eq!(center(&s, b), Point::new(50.0, 300.0)); + assert_eq!(center(&s, c), Point::new(150.0, 300.0)); + assert_eq!(center(&s, a), Point::new(250.0, 300.0)); + let after = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap(); + assert_eq!( + (after.path, after.pivot, after.rotate, after.spacing, after.start), + (path, before.pivot, before.rotate, before.spacing, before.start) + ); + let w = bounds(&s, a); + assert!((w.width() - 20.0).abs() < 1e-9, "the object keeps its art: {w:?}"); + // The last one back to the first place, with its group named. + let v = s.execute("object.objectsOnPath.moveMember", &json!({"group": g.0, "member": a.0, "target": b.0})).unwrap(); + assert_eq!(v["index"], 0); + assert_eq!(orders(&s, g).0, [a, b, c]); + assert_eq!(center(&s, a), Point::new(50.0, 300.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [b, c, a]); + assert_eq!(center(&s, a), Point::new(250.0, 300.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [a, b, c]); + assert_eq!(center(&s, c), Point::new(250.0, 300.0)); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [b, c, a]); + // Onto its own place: nothing changes and no step is recorded. + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": c.0, "target": c.0})).unwrap(); + assert_eq!(v["index"], 1); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // The order saves and reopens. + let data = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "objects.vectorcraft", "dataBase64": data})).unwrap(); + assert_eq!(orders(&o, g), (vec![b, c, a], layers)); + assert_eq!(center(&o, a), Point::new(250.0, 300.0)); +} + +#[test] +fn move_member_refuses_what_is_not_an_object_on_the_same_path() { + let mut s = session(); + let (g, [a, b, c], path) = three_on_a_line(&mut s); + let loose = rect(&mut s, 300.0, 20.0, 10.0, 10.0); + let x = rect(&mut s, 0.0, 200.0, 10.0, 10.0); + let other_line = line(&mut s, (50.0, 380.0), (350.0, 380.0)); + let h = attach(&mut s, other_line, &[x], json!({})); + let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.moveMember", &p).unwrap_err().to_string(); + assert!(err(&mut s, json!({"member": path.0, "target": a.0})).contains("`member` must name an object attached"), "the path"); + assert!(err(&mut s, json!({"member": a.0, "target": path.0})).contains("`target` must name"), "the path as the target"); + assert!(err(&mut s, json!({"member": loose.0, "target": a.0})).contains("`member` must name"), "an object on no path"); + assert!(err(&mut s, json!({"member": g.0, "target": a.0})).contains("`member` must name"), "the group itself"); + assert!(err(&mut s, json!({"member": a.0, "target": x.0})).contains("same Objects on Path group"), "another group's object"); + assert!(err(&mut s, json!({"member": a.0, "target": b.0, "group": h.0})).contains("`group` must be"), "another group named"); + assert!(err(&mut s, json!({"target": b.0})).contains("missing `member`")); + assert!(err(&mut s, json!({"member": a.0})).contains("missing `target`")); + assert!(err(&mut s, json!({"member": 99999, "target": b.0})).contains("no such object")); + assert!(err(&mut s, json!({"member": "a", "target": b.0})).contains("invalid object id")); + // A locked object neither moves nor gives its place, and is not chosen. + select(&mut s, &[c]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(err(&mut s, json!({"member": c.0, "target": a.0})).contains("`member` names an object that is hidden or locked")); + assert!(err(&mut s, json!({"member": a.0, "target": c.0})).contains("`target` names an object that is hidden or locked")); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": c.0})).is_err()); + s.execute("object.unlockAll", &json!({})).unwrap(); + // A locked object between the two keeps its place, and the others pass over it. + select(&mut s, &[b]); + s.execute("object.lock", &json!({})).unwrap(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap(); + assert_eq!(v["index"], 2); + assert_eq!(orders(&s, g).0, [c, b, a]); + assert_eq!(center(&s, b), Point::new(150.0, 300.0), "the locked object stays where it was"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.unlockAll", &json!({})).unwrap(); + select(&mut s, &[g]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g).unwrap().locked); + let e = err(&mut s, json!({"member": a.0, "target": b.0})); + assert!(e.contains("hidden or locked"), "a locked group: {e}"); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).is_err(), "a locked group's object is not chosen"); + assert_eq!(orders(&s, g).0, [a, b, c], "nothing refused changed the order"); +} + +#[test] +fn select_member_selects_the_group_with_the_object_chosen() { + let mut s = session(); + let (g, [a, b, _], path) = three_on_a_line(&mut s); + let v = s.execute("object.objectsOnPath.selectMember", &json!({"member": b.0})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": b.0})); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.objects.as_slice(), sel.on_path_member), (&[g][..], Some(b))); + // Moving the chosen object keeps it chosen. + s.execute("object.objectsOnPath.moveMember", &json!({"member": b.0, "target": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.on_path_member, Some(b)); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": path.0})).is_err()); + select(&mut s, &[g]); + assert_eq!(s.doc().unwrap().selection.on_path_member, None, "another selection clears it"); + // Both take their objects without a selection. + select(&mut s, &[]); + s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": b.0})).unwrap(); + assert_eq!(orders(&s, g).0[..2], [a, b]); + s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.on_path_member, Some(a)); +} + #[test] fn the_space_saves_and_reopens_and_goes_with_a_copy() { let mut s = session(); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 84e5559851..3dacee533b 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -464,6 +464,50 @@ 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)); + } + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ 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/pathtype.rs b/crates/engine/src/tests/pathtype.rs index 5ead0cbd22..8aa8c05684 100644 --- a/crates/engine/src/tests/pathtype.rs +++ b/crates/engine/src/tests/pathtype.rs @@ -184,6 +184,184 @@ fn a_flip_round_a_closed_path_keeps_the_type_in_place() { assert!(origins(&s, id)[0].distance(first) < 8.0); } +/// A circle of radius 100 round (200, 200), starting at 9 o'clock and running clockwise, with +/// type on it from its start. +fn on_circle(s: &mut Session, text: &str) -> NodeId { + path_type(s, "M100 200 A100 100 0 1 1 300 200 A100 100 0 1 1 100 200 Z", [90.0, 200.0], text) +} + +#[test] +fn center_on_top_and_bottom_of_a_circle() { + let mut s = session(); + let id = on_circle(&mut s, "OOOO"); + assert!(near(brackets(&s, id).0, 0.0), "{:?}", brackets(&s, id)); + let steps = undo_len(&s); + let r = s.execute("type.pathCenter.top", &json!({})).unwrap(); + // Once round, its middle at 12 o'clock, a quarter of the way round: the type sits on the top, centered. + let (start, end) = brackets(&s, id); + assert!(near(start, 0.75) && end.is_none(), "{start} {end:?}"); + assert_eq!((r["ids"].clone(), r["end"].clone()), (json!([id.0]), Value::Null)); + assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center); + let top = ink(&s, id); + assert!((top.center().x - 200.0).abs() < 0.5 && top.y1 < 101.0, "{top:?}"); + assert_eq!((undo_len(&s), last_step(&s)), (steps + 1, "Center on Top".to_string())); + // Again: nothing moves, and no undo step is recorded. + s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert_eq!(undo_len(&s), steps + 1); + // The bottom: 6 o'clock, outside the circle (the type is not flipped). + s.execute("type.pathCenter.bottom", &json!({})).unwrap(); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.25) && end.is_none(), "{start} {end:?}"); + let bottom = ink(&s, id); + assert!((bottom.center().x - 200.0).abs() < 0.5 && bottom.y0 > 299.0, "{bottom:?}"); + assert_eq!((undo_len(&s), last_step(&s)), (steps + 2, "Center on Bottom".to_string())); + // Justify Center stays as it is. + s.execute("text.setStyle", &json!({"justify": "justifyCenter"})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::JustifyCenter); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(brackets(&s, id).0, 0.25)); + // Brackets already in place with another alignment: only the alignment changes, in one step. + s.execute("text.setStyle", &json!({"justify": "left"})).unwrap(); + let (steps, before) = (undo_len(&s), brackets(&s, id)); + s.execute("type.pathCenter.bottom", &json!({})).unwrap(); + assert_eq!((undo_len(&s), brackets(&s, id)), (steps + 1, before)); + assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center); +} + +#[test] +fn center_on_top_twice_records_one_step_for_any_span() { + let mut s = session(); + let id = on_circle(&mut s, "OOOO"); + for (start, end) in [(0.0, 0.2), (0.13, 0.41), (0.37, 0.52), (0.61, 0.97), (0.89, 0.07), (0.3, 0.3001)] { + s.execute("type.pathOptions", &json!({"start": start, "end": end})).unwrap(); + for c in ["type.pathCenter.top", "type.pathCenter.bottom"] { + s.execute(c, &json!({})).unwrap(); + let (steps, placed) = (undo_len(&s), brackets(&s, id)); + // Again: the brackets stay exactly where they are, and no undo step is recorded. + s.execute(c, &json!({})).unwrap(); + assert_eq!((undo_len(&s), brackets(&s, id)), (steps, placed), "{c} {start} {end}"); + } + } +} + +#[test] +fn center_on_top_keeps_the_span_and_measures_the_path_as_drawn() { + let mut s = session(); + let id = on_circle(&mut s, "OOOO"); + // A span of a fifth of the path keeps its length. + s.execute("type.pathOptions", &json!({"end": 0.2})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.15) && end.is_some_and(|e| near(e, 0.35)), "{start} {end:?}"); + // Turned a quarter turn counter-clockwise, its 3 o'clock point, halfway round, is at the top. + s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap(); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + let (start, _) = brackets(&s, id); + assert!(near(start.rem_euclid(1.0), 0.0) || near(start, 1.0), "{start}"); + let bounds = text(&s, id).bounds().unwrap(); + let top = ink(&s, id); + assert!((top.center().x - bounds.center().x).abs() < 0.5 && top.y1 < bounds.y0 + 1.0, "{top:?} {bounds:?}"); + // A rectangle: the middle of its top edge. + let id = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO"); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.625) && end.is_none(), "{start} {end:?}"); + assert!((ink(&s, id).center().x - 200.0).abs() < 0.5); +} + +#[test] +fn center_on_top_moves_every_selected_type_on_a_path_in_one_step() { + let mut s = session(); + let a = on_circle(&mut s, "OOOO"); + let b = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO"); + let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0, p]})).unwrap(); + let steps = undo_len(&s); + let r = s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([a.0, b.0])); + assert!(near(brackets(&s, a).0, 0.75) && near(brackets(&s, b).0, 0.625)); + assert_eq!(undo_len(&s), steps + 1); + // Dynamic Text stays on, and the layout fits the text to the span where it now runs: a fifth of + // the circle, at its bottom. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("type.pathOptions", &json!({"start": 0, "end": 0.2})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + s.execute("type.pathCenter.bottom", &json!({})).unwrap(); + assert!(text(&s, a).dynamic_text && near(brackets(&s, a).0, 0.65), "{:?}", brackets(&s, a)); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, a)); + assert!(lay.fit_scale > 1.5 && !lay.overflow, "{}", lay.fit_scale); + let bottom = ink(&s, a); + assert!((bottom.center().x - 200.0).abs() < 1.0 && bottom.y1 > 300.0, "{bottom:?}"); + // Vertical type on a path moves the same way. + s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap(); + s.execute("type.orientation.vertical", &json!({})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert!(near(brackets(&s, a).0, 0.75)); +} + +#[test] +fn center_on_top_refuses_open_compound_and_threaded_type() { + let mut s = session(); + let circle = on_circle(&mut s, "OOOO"); + let line = on_line(&mut s); + s.execute("select.set", &json!({"ids": [circle.0, line.0]})).unwrap(); + let steps = undo_len(&s); + let before = brackets(&s, circle); + let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string(); + assert!(e.contains("type on an open or compound path cannot be centered on its top or bottom") && e.ends_with(&line.0.to_string()), "{e}"); + // Nothing changes, the type on the circle included, and the same holds for an explicit list. + assert_eq!((undo_len(&s), brackets(&s, circle)), (steps, before)); + let e = s.execute("type.pathCenter.bottom", &json!({"ids": [circle.0, line.0]})).unwrap_err().to_string(); + assert!(e.contains("open or compound path") && e.ends_with(&line.0.to_string()), "{e}"); + s.execute("select.set", &json!({"ids": [circle.0]})).unwrap(); + for bad in [json!({"ids": "all"}), json!({"ids": [999_999]})] { + assert!(s.execute("type.pathCenter.top", &bad).is_err(), "{bad}"); + } + assert_eq!(undo_len(&s), steps); + // Two circles side by side in one path: a compound path. + let pair = path_type( + &mut s, + "M100 450 A40 40 0 1 1 180 450 A40 40 0 1 1 100 450 Z M300 450 A40 40 0 1 1 380 450 A40 40 0 1 1 300 450 Z", + [90.0, 450.0], + "OOOO", + ); + let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string(); + assert!(e.contains("open or compound path") && e.ends_with(&pair.0.to_string()), "{e}"); + // A closed path that is level all round has no height. + let flat = path_type(&mut s, "M100 400 L300 400 Z", [90.0, 400.0], "OOOO"); + let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string(); + assert!(e.contains("type on a path with no length or no height cannot be centered") && e.ends_with(&flat.0.to_string()), "{e}"); + // A frame of a thread is refused, alone or with other type on a closed path. + let threaded = on_circle(&mut s, "Threaded story"); + let frame = s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [threaded.0, frame]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [threaded.0]})).unwrap(); + let before = brackets(&s, threaded); + for ids in [json!([threaded.0]), json!([circle.0, threaded.0])] { + let steps = undo_len(&s); + let e = s.execute("type.pathCenter.top", &json!({ "ids": ids })).unwrap_err().to_string(); + assert!(e.contains("type on a path in a thread cannot be centered on its top or bottom") && e.ends_with(&threaded.0.to_string()), "{e}"); + assert_eq!((undo_len(&s), brackets(&s, threaded)), (steps, before)); + } + // Without type on one closed path selected, the commands are not available. + let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); + for (ids, why) in [ + (json!([p]), "select type on a closed path"), + (json!([line.0]), "open or compound path"), + (json!([circle.0, line.0]), "open or compound path"), + (json!([threaded.0]), "in a thread"), + ] { + s.execute("select.set", &json!({ "ids": ids })).unwrap(); + for c in ["type.pathCenter.top", "type.pathCenter.bottom"] { + let e = s.execute(c, &json!({})).unwrap_err().to_string(); + assert!(e.contains(why), "{c} {ids}: {e}"); + } + } +} + #[test] fn align_to_path_and_spacing() { let mut s = session(); diff --git a/crates/engine/src/tests/pixelalign.rs b/crates/engine/src/tests/pixelalign.rs index adb8bae15d..06057d1fc0 100644 --- a/crates/engine/src/tests/pixelalign.rs +++ b/crates/engine/src/tests/pixelalign.rs @@ -959,3 +959,202 @@ fn not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected() { assert_eq!(s.execute("select.object.notAlignedToPixelGrid", &json!({})).unwrap()["count"], 1); assert_eq!(selected_ids(&s), vec![inside]); } + +/// Run Not Aligned to Pixel Grid with `before` selected, check that it makes no undo step and that +/// its count is the number of objects it selected → the selected ids, sorted. +fn not_aligned(s: &mut Session, before: &[u64]) -> Vec { + s.execute("select.set", &json!({ "ids": before })).unwrap(); + let steps = undo_len(s); + let r = s.execute("select.object.notAlignedToPixelGrid", &json!({})).unwrap(); + let ids = selected_ids(s); + assert_eq!(r["count"], json!(ids.len()), "{r}"); + assert_eq!(undo_len(s), steps); + ids +} + +fn sorted(mut ids: Vec) -> Vec { + ids.sort_unstable(); + ids +} + +/// What Select All selects, sorted. +fn all_ids(s: &mut Session) -> Vec { + s.execute("select.all", &json!({})).unwrap(); + selected_ids(s) +} + +fn kind(s: &Session, id: u64) -> NodeKind { + s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind.clone() +} + +/// Images, symbol instances, point type, area type and type on a path cannot take the property, so +/// Not Aligned to Pixel Grid leaves them out though Select All selects them; the path a symbol was +/// made from is the symbol's art, not an object of the document. +#[test] +fn not_aligned_to_pixel_grid_leaves_out_images_symbol_instances_and_type() { + let mut s = new_doc(); + let p = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 1.0); + let png = crate::tests_links::png(4, 4, [255, 0, 0]); + s.execute("file.place", &json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); + let image = s.doc().unwrap().selection.objects[0].0; + let art = path(&mut s, "M60.5 60 L80 60 L80 80 L60.5 80 Z", 1.0); + let made = s.execute("symbol.new", &json!({"name": "Mark", "ids": [art]})).unwrap()["id"].as_u64().unwrap(); + let placed = s.execute("symbol.place", &json!({"name": "Mark", "x": 200.3, "y": 200.3})).unwrap()["id"].as_u64().unwrap(); + let point = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "point"})).unwrap()["id"].as_u64().unwrap(); + let frame = json!({"width": 80.5, "height": 40.5}); + let area = s.execute("text.create", &json!({"x": 100, "y": 150, "text": "area", "area": frame})).unwrap()["id"].as_u64().unwrap(); + let on_path = path(&mut s, "M10.5 300 C100 250 200 350 300.5 300", 1.0); + let on_path = s.execute("text.createInPath", &json!({"path": on_path, "mode": "onPath", "text": "on a path"})).unwrap()["id"].as_u64().unwrap(); + let in_path = path(&mut s, "M250.5 20.5 L350 20.5 L350 90 L250.5 90 Z", 1.0); + let in_path = s.execute("text.createInPath", &json!({"path": in_path, "mode": "area", "text": "in a path"})).unwrap()["id"].as_u64().unwrap(); + assert!(matches!(kind(&s, image), NodeKind::Image(_))); + for id in [made, placed] { + assert!(matches!(kind(&s, id), NodeKind::SymbolInstance { .. }), "{id}"); + } + for id in [point, area, on_path, in_path] { + assert!(matches!(kind(&s, id), NodeKind::Text(_)), "{id}"); + } + assert!(s.doc().unwrap().doc.node(NodeId(art)).is_none()); + assert_eq!(all_ids(&mut s), sorted(vec![p, image, made, placed, point, area, on_path, in_path])); + assert_eq!(not_aligned(&mut s, &[image, made, point]), vec![p]); + // With the path aligned, nothing is left to select. + s.execute("object.setPixelAligned", &json!({"ids": [p]})).unwrap(); + assert_eq!(not_aligned(&mut s, &[placed]), Vec::::new()); +} + +/// Paths on a template layer, in a hidden or locked sublayer or on a locked artboard are left out, +/// as Select All leaves them out; paths in an unlocked visible sublayer are selected. +#[test] +fn not_aligned_to_pixel_grid_leaves_out_template_layers_locked_sublayers_and_locked_artboards() { + let mut s = new_doc(); + s.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 100})).unwrap(); + let main = s.doc().unwrap().doc.layers[0].id.0; + let free = path(&mut s, "M10 10 L20 10 L20 20 L10 20 Z", 1.0); + let on_locked_board = path(&mut s, "M510 10 L520 10 L520 20 L510 20 Z", 1.0); + let template = s.execute("layer.new", &json!({"name": "Trace"})).unwrap()["id"].as_u64().unwrap(); + let traced = path(&mut s, "M30 10 L40 10 L40 20 L30 20 Z", 1.0); + s.execute("layer.setProps", &json!({"id": template, "template": true})).unwrap(); + let sub = |s: &mut Session, name: &str, d: &str| { + let l = s.execute("layer.newSublayer", &json!({"parent": main, "name": name})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.setCurrent", &json!({"id": l})).unwrap(); + (l, path(s, d, 1.0)) + }; + let (_, in_sublayer) = sub(&mut s, "Open", "M50 10 L60 10 L60 20 L50 20 Z"); + let (hidden, in_hidden) = sub(&mut s, "Hidden", "M70 10 L80 10 L80 20 L70 20 Z"); + let (locked, in_locked) = sub(&mut s, "Locked", "M90 10 L100 10 L100 20 L90 20 Z"); + s.execute("layer.setProps", &json!({"id": hidden, "visible": false})).unwrap(); + s.execute("layer.setProps", &json!({"id": locked, "locked": true})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + let all = all_ids(&mut s); + for id in [traced, in_hidden, in_locked, on_locked_board] { + assert!(!all.contains(&id), "{id}"); + } + assert_eq!(not_aligned(&mut s, &[free]), sorted(vec![free, in_sublayer])); + // Unlocked, the artboard's path is selected again. + s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap(); + assert_eq!(not_aligned(&mut s, &[free]), sorted(vec![free, in_sublayer, on_locked_board])); +} + +/// A clipping path and the paths it clips are selected, not their clip group; a path with a drop +/// shadow or a Gaussian Blur is selected as the path it is, and its effects are not objects. +#[test] +fn not_aligned_to_pixel_grid_selects_clipping_paths_and_paths_with_effects() { + let mut s = new_doc(); + let clipped = path(&mut s, "M10.5 10 L60 10 L60 60 L10.5 60 Z", 1.0); + let clip = path(&mut s, "M20.5 20 L50 20 L50 50 L20.5 50 Z", 1.0); + s.execute("select.set", &json!({"ids": [clipped, clip]})).unwrap(); + let group = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + let shadowed = path(&mut s, "M100.5 10 L150 10 L150 60 L100.5 60 Z", 1.0); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [shadowed]})).unwrap(); + let blurred = path(&mut s, "M200.5 10 L250 10 L250 60 L200.5 60 Z", 1.0); + s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 3}, "ids": [blurred]})).unwrap(); + assert!(matches!(kind(&s, group), NodeKind::Group { .. })); + for id in [shadowed, blurred] { + assert!(!s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects.is_empty(), "{id}"); + } + let want = sorted(vec![clipped, clip, shadowed, blurred]); + assert_eq!(not_aligned(&mut s, &[group]), want); + // Object › Align to Pixel Grid takes each object selected; then nothing is left to select. + s.execute("object.setPixelAligned", &json!({"ids": want})).unwrap(); + assert_eq!(not_aligned(&mut s, &[group]), Vec::::new()); +} + +/// The filter looks into blends, envelopes, repeats and Intertwine groups and selects the paths +/// they are made of, not the objects that hold them; Align to Pixel Grid takes that selection, and +/// then nothing is left to select. +#[test] +fn not_aligned_to_pixel_grid_selects_the_paths_of_blends_envelopes_repeats_and_intertwines() { + let mut s = new_doc(); + let b1 = path(&mut s, "M10.5 10 L30 10 L30 30 L10.5 30 Z", 1.0); + let b2 = path(&mut s, "M80.5 10 L100 10 L100 30 L80.5 30 Z", 1.0); + s.execute("select.set", &json!({"ids": [b1, b2]})).unwrap(); + let blend = s.execute("object.blend.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + let warped = path(&mut s, "M10.5 100 L100 100 L100 150 L10.5 150 Z", 1.0); + let envelope = s.execute("object.envelope.makeWithWarp", &json!({"ids": [warped]})).unwrap()["id"].as_u64().unwrap(); + let tile = path(&mut s, "M200.5 10 L220 10 L220 30 L200.5 30 Z", 1.0); + let repeat = s.execute("object.repeat.grid", &json!({"ids": [tile], "rows": 2, "cols": 2})).unwrap()["id"].as_u64().unwrap(); + let i1 = path(&mut s, "M200.5 200 L260 200 L260 230 L200.5 230 Z", 1.0); + let i2 = path(&mut s, "M220.5 180 L240 180 L240 250 L220.5 250 Z", 1.0); + let intertwine = s.execute("object.intertwine.make", &json!({"ids": [i1, i2]})).unwrap()["id"].as_u64().unwrap(); + assert!(matches!(kind(&s, blend), NodeKind::Blend { .. })); + assert!(matches!(kind(&s, envelope), NodeKind::Envelope { .. })); + assert!(matches!(kind(&s, repeat), NodeKind::Repeat(_))); + assert!(matches!(kind(&s, intertwine), NodeKind::Intertwine { .. })); + let want = sorted(vec![b1, b2, warped, tile, i1, i2]); + assert_eq!(not_aligned(&mut s, &[blend, envelope]), want); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + for id in &want { + assert!(aligned(&s, *id), "{id}"); + } + assert_eq!(not_aligned(&mut s, &[repeat, intertwine]), Vec::::new()); +} + +/// Gradient meshes, placed documents and ruler guides are left out; a live shape is a path and is +/// selected. +#[test] +fn not_aligned_to_pixel_grid_leaves_out_meshes_placed_documents_and_guides_but_takes_live_shapes() { + let mut s = new_doc(); + let filled = path(&mut s, "M10.5 10 L60 10 L60 60 L10.5 60 Z", 1.0); + s.execute("paint.setFill", &json!({"color": "#3366cc"})).unwrap(); + let mesh = s.execute("object.mesh.create", &json!({"ids": [filled], "rows": 2, "cols": 2})).unwrap()["ids"][0].as_u64().unwrap(); + let dir = crate::tests_links::Folder::new("pixelalign-placed"); + let file = dir.file("placed.vectorcraft"); + let mut d = vectorcraft_doc::Document::new(50.0, 50.0); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.5, 0.5, 40.0, 40.0)); + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(20, 20, 230)); + let look = vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0); + d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, rect, look)).unwrap(); + crate::tests_links::write(&file, &vectorcraft_format::save_file(&d)); + let placed = s.execute("file.place", &json!({"path": file, "at": [200, 200]})).unwrap()["ids"][0].as_u64().unwrap(); + let guide = path(&mut s, "M0 300.5 L400 300.5", 1.0); + s.execute("view.guides.make", &json!({})).unwrap(); + let live = s.execute("shape.rectangle", &json!({"x": 100.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + assert!(matches!(kind(&s, mesh), NodeKind::Mesh(_))); + assert!(matches!(kind(&s, placed), NodeKind::PlacedDocument(_))); + assert!(matches!(kind(&s, guide), NodeKind::Path { guide: true, .. })); + assert!(s.doc().unwrap().doc.node(NodeId(live)).unwrap().live_shape().is_some()); + let all = all_ids(&mut s); + assert!(all.contains(&mesh) && all.contains(&placed), "{all:?}"); + assert_eq!(not_aligned(&mut s, &[mesh, placed]), vec![live]); +} + +/// A compound path goes by its own property, whatever the property of the paths in it (a file can +/// hold paths with the property on inside a compound path): off, it is selected as one object and +/// the paths in it are not; on, it is not selected, though a path in it has the property off. +#[test] +fn not_aligned_to_pixel_grid_takes_a_compound_path_by_its_own_property() { + let mut s = new_doc(); + let on = path(&mut s, "M10.5 10 L30 10 L30 30 L10.5 30 Z", 1.0); + let off = path(&mut s, "M50.5 10 L70 10 L70 30 L50.5 30 Z", 1.0); + s.execute("select.set", &json!({"ids": [on, off]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(on)).unwrap().extras_mut().pixel_aligned = true; + assert!(aligned(&s, on) && !aligned(&s, off) && !aligned(&s, compound)); + let other = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 1.0); + assert_eq!(not_aligned(&mut s, &[other]), sorted(vec![compound, other])); + s.execute("object.setPixelAligned", &json!({"ids": [compound]})).unwrap(); + assert!(!aligned(&s, off)); + assert_eq!(not_aligned(&mut s, &[compound]), vec![other]); +} diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a1e615a08d..8d1570e996 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -505,6 +505,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"); @@ -1433,6 +1457,92 @@ fn plugins_folder_set_again_after_clearing_installs_its_plugins() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// Under automation read roots, `plugin.list` names the plug-in folder last loaded, its failed +/// files and a plug-in's source file only inside them: a folder outside is `null`, and a file +/// outside is named by its file name. +#[test] +fn plugin_list_leaves_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + let base = crate::tests_links::Folder::new("plugin-list-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let id = "test.engine.listroots"; + let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); + std::fs::write(outside.join("hidden.wasm"), wat::parse_str(src).unwrap()).unwrap(); + std::fs::write(outside.join("broken.wasm"), b"\0asm?").unwrap(); + let folder = outside.to_string_lossy().to_string(); + let mut s = session(); + let r = run(&mut s, "plugin.reload", json!({"path": folder})); + assert_eq!(r["loaded"], json!([id]), "{r}"); + let broken = outside.join("broken.wasm").to_string_lossy().to_string(); + let source = outside.join("hidden.wasm").to_string_lossy().to_string(); + let entry = |list: &Value| list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap(); + + // The person sees the paths. + let person = run(&mut s, "plugin.list", json!({})); + assert_eq!((&person["folder"]["folder"], &person["folder"]["failed"][0]["file"]), (&json!(folder), &json!(broken)), "{person}"); + assert_eq!(entry(&person)["source"], json!(source)); + + // The client sees their names. + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + let report = &listed["folder"]; + assert_eq!((&report["folder"], &report["loaded"], &report["failed"][0]["file"]), (&Value::Null, &json!([id]), &json!("broken.wasm")), "{listed}"); + assert_eq!(entry(&listed)["source"], "hidden.wasm"); + let info = confine(roots.as_ref(), || s.execute("plugin.info", &json!({"id": id}))).unwrap(); + assert_eq!(info["source"], "hidden.wasm"); + let outside_text = json!(folder).to_string(); + for reply in [&listed, &info] { + assert!(!reply.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {reply}"); + } + + // A read root holding the folder shows it whole. + let wider = AutomationRoots::new(&[&outside], &[]).unwrap(); + let listed = confine(wider.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + assert_eq!(listed["folder"]["folder"], json!(folder), "{listed}"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// Under automation read roots, a plug-in turned off for the next launch is listed with a file +/// outside them named by its file name, and `plugin.setEnabled` turns it on by that name. +#[test] +fn disabled_plugins_name_files_outside_the_read_root_by_their_names() { + if !in_own_process("disabled_plugins_name_files_outside_the_read_root_by_their_names") { + return; + } + use crate::file_access::{AutomationRoots, confine}; + let base = crate::tests_links::Folder::new("plugin-disabled-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let id = "test.engine.disabledroots"; + let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); + let file = outside.join("off.wasm"); + std::fs::write(&file, wat::parse_str(src).unwrap()).unwrap(); + let file = file.to_string_lossy().to_string(); + let mut s = session(); + run(&mut s, "plugin.install", json!({"path": file})); + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + // The client turns it off by the source plugin.list shows. + let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + let source = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).unwrap()["source"].clone(); + assert_eq!(source, "off.wasm", "{listed}"); + confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": source, "enabled": false}))).unwrap(); + assert_eq!(run(&mut s, "plugin.safeMode", json!({}))["disabled"][0]["file"], json!(file), "the person sees the path"); + + let outside_text = json!(outside.to_string_lossy()).to_string(); + for cmd in ["plugin.list", "plugin.safeMode"] { + let r = confine(roots.as_ref(), || s.execute(cmd, &json!({}))).unwrap(); + assert_eq!(r["disabled"][0]["file"], "off.wasm", "{cmd}: {r}"); + assert!(!r.to_string().contains(outside_text.trim_matches('"')), "{cmd}: no path outside the root: {r}"); + } + let r = confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": "off.wasm", "enabled": true}))).unwrap(); + assert_eq!((&r["changed"], &r["disabled"]), (&json!(true), &json!([])), "{r}"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// #2541: a plug-in installed in a session with a plug-in store is installed again in the next /// session that attaches the store, and Remove Plug-in takes it out of the store. #[test] diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 8248f12a48..1f3867a622 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1545,6 +1545,71 @@ impl RecoveryStore for DiesAfterDocument { } } +/// Under automation read roots, `file.recovery.save`, `file.recovery.list` and +/// `file.recovery.restore` name a document's file and the recovery folder only inside them. +#[test] +fn recovery_replies_leave_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + use serde_json::Value; + let base = crate::tests_links::Folder::new("recovery-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let store: Arc = Arc::new(FolderStore::new(outside.join("recovery"))); + let location = outside.join("recovery").to_string_lossy().to_string(); + let kept = inside.join("kept.vectorcraft").to_string_lossy().to_string(); + let hidden = outside.join("hidden.vectorcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + for path in [&kept, &hidden] { + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + } + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let saved = confine(roots.as_ref(), || s.execute("file.recovery.save", &json!({}))).unwrap(); + assert_eq!(saved["location"], Value::Null, "{saved}"); + s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap(); + assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["location"], json!(location), "the person sees the folder"); + + let sorted = |v: &Value, key: &str| -> Vec { + let mut out: Vec = v[key].as_array().unwrap().iter().map(|c| c["path"].to_string()).collect(); + out.sort(); + out + }; + let both = |first: Value| { + let mut out = vec![first.to_string(), json!(kept).to_string()]; + out.sort(); + out + }; + assert_eq!(sorted(&s.execute("file.recovery.list", &json!({})).unwrap(), "copies"), both(json!(hidden))); + let listed = confine(roots.as_ref(), || s.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!((sorted(&listed, "copies"), &listed["location"]), (both(Value::Null), &Value::Null), "{listed}"); + let outside_text = json!(outside.to_string_lossy()).to_string(); + assert!(!listed.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {listed}"); + // A relative recovery folder is checked as the file system reads it; a store that is no folder + // keeps its name. + let mut relative = Session::new(); + relative.recovery.set_store(Arc::new(FolderStore::new("vc-recovery-relative-folder"))); + let l = confine(roots.as_ref(), || relative.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!(l["location"], Value::Null, "{l}"); + let (mut memory, _) = session(); + let l = confine(roots.as_ref(), || memory.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!(l["location"], "memory", "{l}"); + + // After a crash, the copies restored. + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + let r = confine(roots.as_ref(), || s.execute("file.recovery.restore", &json!({}))).unwrap(); + assert_eq!(sorted(&r, "restored"), both(Value::Null), "{r}"); + let mut opened: Vec> = s.documents().iter().map(|d| d.path.clone()).collect(); + opened.sort(); + let mut files = vec![Some(hidden), Some(kept)]; + files.sort(); + assert_eq!(opened, files, "the documents keep their files"); +} + /// A crash between writing a copy's document and its details restores the new document with /// every image, the one placed since the last copy too (and the first copy likewise). #[test] diff --git a/crates/engine/src/tests/script_console.rs b/crates/engine/src/tests/script_console.rs index 27f53cef07..48d5c8ed0a 100644 --- a/crates/engine/src/tests/script_console.rs +++ b/crates/engine/src/tests/script_console.rs @@ -265,3 +265,31 @@ fn the_slowest_commands_are_the_longest_in_all_then_by_id() { assert_eq!(ids, ["b.two", "a.one", "c.three", "f", "e"]); assert_eq!((slow[0].calls, slow[0].total_ms, slow[0].max_ms), (3, 9, 4)); } + +/// Under automation read roots, the error lines of a failed run name the included file it stopped +/// in by its file name also when the console cut a line inside the file's path. +#[test] +fn a_cut_error_line_leaves_out_an_included_files_folder_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + let base = crate::tests_links::Folder::new("script-report-cut-line"); + let inside = base.0.join("in"); + std::fs::create_dir_all(&inside).unwrap(); + // A path longer than a console line, as the report of a failed run names it. + let file = base.0.join("out").join("d".repeat(MAX_LINE_BYTES)).join("bad.jsxinc").to_string_lossy().to_string(); + let mut report = json!({"ok": false, "name": "t.js", "file": file, "line": 2, "column": 7, "message": format!("Error: stopped\nin {file}")}); + let error = script::error_lines(&report); + assert!(error.len() == 2 && error.iter().all(|l| l.len() <= MAX_LINE_BYTES && l.ends_with(CUT_MARK)), "both lines are cut: {error:?}"); + let written: Vec = std::iter::once("written by the script".to_string()).chain(error).collect(); + report["console"] = json!({"lines": written, "droppedLines": 0, "truncatedLines": 2}); + let mut s = Session::new(); + s.script_report = Some(report.clone()); + + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let shown = confine(roots.as_ref(), || s.shown_script_report()).unwrap(); + assert_eq!((&shown["file"], &shown["message"]), (&json!("bad.jsxinc"), &json!("Error: stopped\nin bad.jsxinc")), "{shown}"); + assert_eq!(shown["console"]["lines"], json!(["written by the script", "t.js: bad.jsxinc:2:7: Error: stopped", "in bad.jsxinc"]), "{shown}"); + let outside = json!(base.0.join("out").to_string_lossy()).to_string(); + assert!(!shown.to_string().contains(outside.trim_matches('"')), "no path outside the root: {shown}"); + // The person sees the report as it is. + assert_eq!(s.shown_script_report(), Some(report)); +} diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index d8c5d1d2e4..e056ea2f7c 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -230,7 +230,7 @@ fn script_file_link_escape_refused() { /// not matter, and outside it where case matters, whether or not the file is there. #[test] fn script_file_case_variant_folder() { - let base = folder("case"); + let base = crate::cmd::findfiles::plain(folder("case").canonicalize().unwrap()); write(&base.join("main/own.txt"), b"own"); let upper = base.join("MAIN"); let source = |name: &str| { diff --git a/crates/engine/src/tests/select_effects.rs b/crates/engine/src/tests/select_effects.rs index b30d0ef70e..d2160fba34 100644 --- a/crates/engine/src/tests/select_effects.rs +++ b/crates/engine/src/tests/select_effects.rs @@ -234,3 +234,160 @@ fn a_marquee_that_touches_only_a_drop_shadow_does_not_select_its_object() { // From above B, over B and the shadow: B only. assert_eq!(press(&mut s, "selection", (105.0, 5.0), (115.0, 30.0), Mods::default()), [b]); } + +/// The selection after a drag with `tool` from `from` to `to`. +fn marquee(s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64)) -> vectorcraft_doc::Selection { + press(s, tool, from, to, Mods::default()); + s.doc().unwrap().selection.clone() +} + +/// The point of anchor `(si, ai)` of path `id`. +fn anchor_point(s: &Session, id: NodeId, (si, ai): vectorcraft_doc::AnchorRef) -> Point { + let path = s.doc().unwrap().doc.node(id).and_then(|n| n.path_data()).unwrap(); + path.anchors().find(|(s, i, _)| (*s, *i) == (si, ai)).map(|(.., a)| a.p).unwrap() +} + +/// The anchors a marquee or a click picked on `id`, as points. +fn picked_points(s: &Session, id: NodeId) -> Vec { + let refs: Vec<_> = s.doc().unwrap().selection.partial(id).into_iter().flatten().copied().collect(); + refs.into_iter().map(|r| anchor_point(s, id, r)).collect() +} + +/// The anchors the tool marks as hovered. +fn anchor_marks(s: &mut Session) -> Vec { + s.overlays(ViewInfo::default()) + .into_iter() + .filter_map(|o| if let vectorcraft_tools::Overlay::Anchor { p, .. } = o { Some(p) } else { None }) + .collect() +} + +fn hover(s: &mut Session, x: f64, y: f64) -> Cursor { + let v = ViewInfo::default(); + s.pointer(&PointerEvent::new(PointerKind::Move, x, y), v).unwrap(); + s.cursor(Point::new(x, y), Mods::default(), v) +} + +#[test] +fn a_direct_selection_marquee_leaves_out_a_drop_shadow() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + let (a_before, b_before) = (bounds(&s, a), bounds(&s, b)); + assert_ne!(pixel(&s, 90, 50), WHITE); + run(&mut s, "select.none", json!({})); + // Over the shadow alone: no object, no anchor, no segment. + let sel = marquee(&mut s, "directSelection", (85.0, 40.0), (95.0, 60.0)); + assert!(sel.objects.is_empty() && sel.anchors.is_empty() && sel.segments.is_empty(), "{sel:?}"); + // Over the shadow and B's upper left corner: that anchor of B, and nothing of A. + let sel = marquee(&mut s, "directSelection", (90.0, 5.0), (105.0, 30.0)); + assert_eq!(sel.objects, [b]); + assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]); + // Across B's top edge under the shadow, with neither of its anchors inside: that segment. + run(&mut s, "select.none", json!({})); + assert_ne!(pixel(&s, 112, 25), BLUE); + let sel = marquee(&mut s, "directSelection", (110.0, 5.0), (115.0, 30.0)); + assert_eq!(sel.objects, [b]); + assert!(sel.segments.contains_key(&b) && !sel.segments.contains_key(&a), "{sel:?}"); + assert_eq!((bounds(&s, a), bounds(&s, b)), (a_before, b_before)); +} + +#[test] +fn direct_selection_hover_leaves_out_a_drop_shadow_and_marks_the_anchor_under_it() { + let mut s = session(); + let (_, b) = shadow_over_b(&mut s); + // B's upper left corner lies under the shadow. + assert_ne!(pixel(&s, 101, 21), BLUE); + run(&mut s, "select.none", json!({})); + s.select_tool("directSelection", ViewInfo::default()).unwrap(); + for (x, y) in [(90.0, 50.0), (110.0, 50.0)] { + assert_eq!(hover(&mut s, x, y), Cursor::ArrowHollow, "({x}, {y})"); + assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})"); + } + assert_eq!(hover(&mut s, 101.0, 21.0), Cursor::DirectAnchor); + assert_eq!(anchor_marks(&mut s), [Point::new(100.0, 20.0)]); + // A press there picks that anchor of B. + assert_eq!(press(&mut s, "directSelection", (101.0, 21.0), (101.0, 21.0), Mods::default()), [b]); + assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]); +} + +#[test] +fn a_group_selection_click_on_a_drop_shadow_selects_the_object_under_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]); + assert!(press(&mut s, "groupSelection", (90.0, 50.0), (90.0, 50.0), Mods::default()).is_empty()); + assert_eq!(press(&mut s, "groupSelection", (50.0, 50.0), (50.0, 50.0), Mods::default()), [a]); +} + +#[test] +fn group_selection_clicks_on_a_drop_shadow_climb_the_groups_of_the_object_under_it() { + let mut s = session(); + // B and C in one group, A and D in another above it, and A's shadow over B. + let b = rect(&mut s, 100.0, 20.0, 60.0, 60.0, "#0000ff"); + let c = rect(&mut s, 170.0, 20.0, 20.0, 20.0, "#0000ff"); + run(&mut s, "select.set", json!({"ids": [b.0, c.0]})); + let gb = id_of(&run(&mut s, "object.group", json!({}))); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + let d = rect(&mut s, 20.0, 120.0, 20.0, 20.0, "#ff0000"); + run(&mut s, "select.set", json!({"ids": [a.0, d.0]})); + let ga = id_of(&run(&mut s, "object.group", json!({}))); + shadow(&mut s, a, 40.0); + assert_ne!(pixel(&s, 110, 50), BLUE); + assert_ne!(pixel(&s, 90, 50), WHITE); + run(&mut s, "select.none", json!({})); + let click = |s: &mut Session, x, y| { + let mut sel = press(s, "groupSelection", (x, y), (x, y), Mods::default()); + sel.sort_by_key(|n| n.0); + sel + }; + assert_eq!(click(&mut s, 110.0, 50.0), [b]); + let mut both = vec![b, gb]; + both.sort_by_key(|n| n.0); + assert_eq!(click(&mut s, 110.0, 50.0), both); + // With A selected, a click on its shadow over the canvas does not add A's group. + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert!(click(&mut s, 90.0, 50.0).is_empty()); + // On A itself the second click adds it. + assert_eq!(click(&mut s, 50.0, 50.0), [a]); + let mut both = vec![a, ga]; + both.sort_by_key(|n| n.0); + assert_eq!(click(&mut s, 50.0, 50.0), both); +} + +#[test] +fn a_group_selection_marquee_leaves_out_a_drop_shadow() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + let sel = marquee(&mut s, "groupSelection", (85.0, 40.0), (95.0, 60.0)); + assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}"); + // Over the shadow and B's upper left corner: that anchor of B. + let sel = marquee(&mut s, "groupSelection", (90.0, 5.0), (105.0, 30.0)); + assert_eq!(sel.objects, [b]); + assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]); + // With E, a marquee around B that crosses A's shadow selects B whole, and not A. + run(&mut s, "select.none", json!({})); + let v = ViewInfo::default(); + s.select_tool("groupSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 95.0, 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 130.0, 50.0), v).unwrap(); + s.tool_key(vectorcraft_tools::ToolKey::Letter('e'), Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 165.0, 90.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 165.0, 90.0), v).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.objects, [b]); + assert!(sel.partial(b).is_none() && sel.partial(a).is_none(), "{sel:?}"); +} + +#[test] +fn group_selection_hover_over_a_drop_shadow_marks_nothing() { + let mut s = session(); + let (_, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + s.select_tool("groupSelection", ViewInfo::default()).unwrap(); + for (x, y) in [(90.0, 50.0), (110.0, 50.0), (101.0, 21.0)] { + assert_eq!(hover(&mut s, x, y), Cursor::GroupSelection, "({x}, {y})"); + assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})"); + } + assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]); +} diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 695a2962a5..b380a4e06b 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -844,7 +844,7 @@ fn a_grit_texture_adds_once_undoes_and_saves() { let list = run(&mut s, "swatch.library.list", json!({})); let ids: Vec<&str> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["geometric-patterns", "grit-textures"]); + assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]); let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"})); let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]); @@ -869,6 +869,49 @@ fn a_grit_texture_adds_once_undoes_and_saves() { assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint)); } +/// Optical Effects is listed after Grit Textures, and reading it adds nothing. Clicking one of +/// its patterns brings it with its swatch and fills the selection with it, once, as one undo step +/// that also takes both away, and both stay after a save and reopen. +#[test] +fn an_optical_effect_adds_once_fills_undoes_and_saves() { + let mut s = session(); + let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count()); + let empty = counts(doc(&s)); + let list = run(&mut s, "swatch.library.list", json!({})); + let ids: Vec<&str> = + list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]); + let got = run(&mut s, "swatch.library.get", json!({"library": "Optical Effects"})); + let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect(); + assert_eq!( + names, + ["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"] + ); + assert_eq!(counts(doc(&s)), empty, "reading a library adds no pattern or swatch"); + let id = vectorcraft_doc::NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap()); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"], "apply": "fill"})); + assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Warped Checks"]), json!("Warped Checks"))); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1)); + let def = doc(&s).pattern("Warped Checks").expect("the pattern comes with its swatch").clone(); + assert_eq!(def.art.len(), 32, "the black cells of an 8 × 8 checkerboard"); + assert!(matches!(doc(&s).swatch("Warped Checks").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Warped Checks")); + let filled = |d: &vectorcraft_doc::Document| matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Warped Checks"); + assert!(filled(doc(&s))); + let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"]})); + assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Warped Checks"]))); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(counts(doc(&s)), empty); + assert!(doc(&s).pattern("Warped Checks").is_none() && doc(&s).swatch("Warped Checks").is_none() && !filled(doc(&s))); + run(&mut s, "edit.redo", json!({})); + assert!(filled(doc(&s))); + let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap(); + assert_eq!(back.pattern("Warped Checks"), Some(&def)); + assert_eq!(back.swatch("Warped Checks").map(|w| &w.paint), doc(&s).swatch("Warped Checks").map(|w| &w.paint)); + assert!(filled(&back)); +} + /// A device name is saved with a `-` after it, which can be another library's own name: neither /// library replaces the other, and saving either again replaces only itself. #[test] diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index abb39c0d96..46d41adaea 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -139,6 +139,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] @@ -3166,6 +3214,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 +3641,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/warp_images.rs b/crates/engine/src/tests/warp_images.rs new file mode 100644 index 0000000000..47175fc866 --- /dev/null +++ b/crates/engine/src/tests/warp_images.rs @@ -0,0 +1,293 @@ +//! Effect › Warp on placed images: the pixels bend with the frame, in the canvas and in exports. + +use serde_json::{Value, json}; +use vectorcraft_doc::live::{WarpNet, WarpStyle, uses_net, warp_version}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{Affine, Point, Rect}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +/// The box of the image [`quarters`] places. +const FRAME: Rect = Rect::new(100.0, 100.0, 180.0, 180.0); + +/// The color of the quarter of the image [`quarters`] places that holds (u, v) of its box: red top +/// left, green top right, blue bottom left, yellow bottom right. +fn quarter(u: f64, v: f64) -> [u8; 3] { + match (u < 0.5, v < 0.5) { + (true, true) => [255, 0, 0], + (false, true) => [0, 255, 0], + (true, false) => [0, 0, 255], + (false, false) => [255, 255, 0], + } +} + +/// A selected embedded 40×40 px image filling [`FRAME`], each quarter one color ([`quarter`]). +fn quarters(s: &mut Session) -> NodeId { + let img = image::RgbaImage::from_fn(40, 40, |x, y| { + let [r, g, b] = quarter(x as f64 / 40.0, y as f64 / 40.0); + image::Rgba([r, g, b, 255]) + }); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, sel| { + d.images.insert("quarters".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0); + let im = vectorcraft_doc::ImageObject { + key: "quarters".into(), + width: 40, + height: 40, + xf, + 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() +} + +/// Where an Arc warp bending `bend` % takes the point at (u, v) of [`FRAME`]. +fn arced(bend: f64, u: f64, v: f64) -> Point { + let net = WarpNet::new(WarpStyle::Arc, bend / 100.0, 0.0, 0.0, FRAME.width(), FRAME.height(), false); + FRAME.origin() + net.at(u, v).to_vec2() +} + +/// Points of the image's box: near each quarter's middle and on both sides of the line between +/// the top and bottom quarters, where a warp that moves the pixels up or down changes the color. +const SAMPLES: [(f64, f64); 10] = + [(0.2, 0.2), (0.8, 0.2), (0.2, 0.8), (0.8, 0.8), (0.4, 0.4), (0.6, 0.4), (0.4, 0.6), (0.6, 0.6), (0.3, 0.62), (0.7, 0.62)]; + +/// The document drawn over (0, 0)–(400, 400) at one pixel a point, on white. +fn drawn(s: &Session) -> vectorcraft_render::Rendered { + vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 400.0, 400.0), 1.0, true) +} + +/// The sample points whose color in `img` at `at(u, v)` is not the image's color at (u, v). +fn wrong(img: &vectorcraft_render::Rendered, at: &dyn Fn(f64, f64) -> Point) -> Vec<(f64, f64, [u8; 4])> { + SAMPLES + .iter() + .filter_map(|&(u, v)| { + let p = at(u, v); + let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32); + let want = quarter(u, v); + let near = (0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40); + (!near).then_some((u, v, px)) + }) + .collect() +} + +/// `quarters` with Effect › Warp › Arc at 50 %: the warp the effect holds, checked to be the +/// Bézier patch [`arced`] follows. +fn arc_image(s: &mut Session) -> NodeId { + let id = quarters(s); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let e = &n.appearance.effects[0]; + assert!(warp_version(&e.params["version"]).is_some_and(uses_net), "{}", e.params); + id +} + +fn decoded(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +#[test] +fn an_images_pixels_bend_with_effect_warp() { + let mut s = session(); + let id = arc_image(&mut s); + // The arc lifts the middle of the image: the line between the quarters rises there. + assert!(arced(50.0, 0.5, 0.6).y < FRAME.y0 + 0.5 * FRAME.height(), "{:?}", arced(50.0, 0.5, 0.6)); + let bent = drawn(&s); + assert_eq!(wrong(&bent, &|u, v| arced(50.0, u, v)), vec![], "each pixel is drawn where the warp takes it"); + // Drawn as pieces of the one image, each placed by the warp. + let d = &s.doc().unwrap().doc; + let art = vectorcraft_render::effects::reshape(Some(d), d.node(id).unwrap(), None).unwrap(); + let mut pieces = 0; + art.walk(&mut |c| pieces += matches!(c.kind, NodeKind::Image(_)) as usize); + assert!(pieces >= 100, "{pieces} pieces"); + // Removing the warp draws the image as it was, in one undo step. + let steps = s.doc().unwrap().history.undo.len(); + s.execute("effect.remove", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let plain = |u: f64, v: f64| Point::new(FRAME.x0 + u * FRAME.width(), FRAME.y0 + v * FRAME.height()); + assert_eq!(wrong(&drawn(&s), &plain), vec![]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![], "undo brings the bend back"); +} + +/// `out`, an export of [`arc_image`]'s document named `name`, opened again: it draws the image +/// bent. +fn opens_bent(name: &str, out: &Value) { + let mut back = session(); + back.execute("document.open", &json!({"name": name, "dataBase64": out["dataBase64"]})).unwrap(); + assert_eq!(wrong(&drawn(&back), &|u, v| arced(50.0, u, v)), vec![], "{name}"); +} + +#[test] +fn a_warped_image_keeps_its_bend_in_svg() { + let mut s = session(); + arc_image(&mut s); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let text = String::from_utf8(decoded(&svg)).unwrap(); + assert_eq!(text.matches("= 100, "{images} pieces"); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); + s.execute("edit.undo", &json!({})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert!(matches!(n.kind, NodeKind::Image(_)) && n.appearance.effects.len() == 1); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); +} + +/// A selected embedded grayscale 40×40 px image filling [`FRAME`], black above white, colorized +/// red by its fill color: red above, nothing below. +fn red_on_clear(s: &mut Session) -> NodeId { + let img = image::GrayImage::from_fn(40, 40, |_, y| image::Luma([if y < 20 { 0 } else { 255 }])); + let mut png = vec![]; + image::DynamicImage::ImageLuma8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let id = s + .edit("Place", |d, sel| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 40, + height: 40, + xf, + 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!({"color": "#ff0000", "ids": [id.0]})).unwrap(); + id +} + +#[test] +fn a_colorized_image_keeps_its_color_when_warped() { + let mut s = session(); + red_on_clear(&mut s); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); + let img = drawn(&s); + let (red, white) = ([255u8, 0, 0], [255u8, 255, 255]); + for (u, v, want) in [(0.5, 0.35, red), (0.2, 0.2, red), (0.5, 0.65, white), (0.8, 0.8, white)] { + let p = arced(50.0, u, v); + let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32); + assert!((0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40), "({u}, {v}) at {p:?}: {px:?}"); + } +} + +#[test] +fn a_warp_on_a_group_bends_the_image_in_it() { + let mut s = session(); + let im = quarters(&mut s); + // A group of the image and a line below it as wide as the image: the group's box is the image's. + let line = s.execute("shape.line", &json!({"x1": FRAME.x0, "y1": FRAME.y1, "x2": FRAME.x1, "y2": FRAME.y1})).unwrap(); + let line = NodeId(line["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [im.0, line.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); +} 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..4f8188dbe7 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1142,8 +1142,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 { 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/font_folder.rs b/crates/engine/tests/font_folder.rs index b58e79fc17..11d152fb4d 100644 --- a/crates/engine/tests/font_folder.rs +++ b/crates/engine/tests/font_folder.rs @@ -71,6 +71,13 @@ fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() { assert!(st.revision > revision && !st.is_dirty()); assert_eq!(s.execute("text.missingFonts", &json!({})).unwrap()["count"], 0); + // A caller whose read roots hold the file but not the Fonts folder: the file is kept, and the + // reply names the copy by its file name only, without the folder. + let downloads = vectorcraft_engine::file_access::AutomationRoots::new(&[&root.join("Downloads")], &[]).unwrap(); + let r = vectorcraft_engine::file_access::confine(downloads.as_ref(), || s.execute("text.addFontFiles", &json!({"files": [file]}))).unwrap(); + assert_eq!((&r["folder"], &r["kept"]), (&Value::Null, &json!([{"from": file, "to": "Findme 2.otf"}])), "{r}"); + assert!(!r.to_string().contains("App Fonts"), "{r}"); + // The same file again is kept, not copied a third time. let r = s.execute("text.addFontFiles", &json!({"files": [file]})).unwrap(); assert_eq!((r["copied"].clone(), r["kept"].clone()), (json!([]), json!([{"from": file, "to": fonts.join("Findme 2.otf")}])), "{r}"); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 67bb04c33b..4542496fc5 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2682,7 +2682,10 @@ fn arb_ps_token() -> impl Strategy { // Local VM restored: dictionary changes undone, saves restored twice. "/a 1 def save /a 2 def", "restore", "save dup restore", "1 dict save exch /k 1 put", "cachestatus", "/n 0 def save /n n 1 add store", // Executable strings and keys other than names (PLRM 3rd ed., `cvx`, `load`). - "(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit" + "(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit", + // CID-keyed fonts found by name over predefined CMaps, horizontal and vertical. + "/Ryumin-Light-90ms-RKSJ-H", "/Ryumin-Light-EUC-V", "/Ryumin-Light-H", "/GBT-EUC-H", "/KSCms-UHC-V", "/UniJIS-UTF16-V", + "[/Ryumin-Light]", "composefont", "<93fa967b8540fd0d41>", "" ]) .prop_map(str::to_string), ] @@ -4327,6 +4330,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`]. @@ -4439,6 +4453,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..6c35eb7a01 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 @@ -168,9 +178,10 @@ //! 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 4939327c9d..0ccfc23ca8 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -747,6 +747,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 +757,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 +896,7 @@ fn counted() -> Vec<(&'static str, Document)> { ("styles", styles_document()), ("painted clips", painted_clips()), ("blend", blend_doc()), + ("brushes", brushes_document()), ] } @@ -6668,6 +6670,7 @@ fn every_effect_a_graphic_style_holds_is_read_back() { for id in &ids { cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"}))); cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); + cases.push((id, json!({"bend": 45.0, "horizontal": 15.0, "vertical": -5.0, "orientation": "horizontal", "version": 1}))); } for (id, params) in cases { let effect = fx(id, params.clone()); @@ -6684,6 +6687,10 @@ fn every_effect_a_graphic_style_holds_is_read_back() { let got = &back.graphic_styles[0].appearance.effects; assert_eq!(got.len(), 1, "{id} {params}"); assert_eq!(got[0].id, effect.id); + // A warp read from a file bends as its style's Bézier net. + if id.starts_with("warp.") { + assert_eq!(got[0].params["version"], json!(vectorcraft_doc::live::WARP_NET), "{id} {params}"); + } let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params)); for (key, value) in w.as_object().unwrap() { // Random isn't written, and a style with it isn't read; its seed is the default. @@ -6704,6 +6711,19 @@ fn every_effect_a_graphic_style_holds_is_read_back() { } } } + // A warp saved before the map version existed is written with its settings too, and reads back on + // the nets' map. + let old = vectorcraft_doc::Effect { id: "warp.flag".into(), params: json!({"bend": 40.0, "horizontal": 10.0}), visible: true }; + let mut doc = Document::new(200.0, 100.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Old", Appearance { effects: vec![old], ..Appearance::basic(black_fill(), Paint::None, 0.0) }); + g.id = 3; + *doc.graphic_styles = vec![g]; + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + let back = read_back(&doc); + let [warp] = back.graphic_styles[0].appearance.effects.as_slice() else { panic!("{:?}", back.graphic_styles) }; + assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["horizontal"]), ("warp.flag", &json!(40.0), &json!(10.0))); + assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET)); // A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians. let mut doc = Document::new(200.0, 100.0); let effects = vec![ @@ -6893,6 +6913,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] @@ -7240,6 +7282,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 @@ -7658,3 +8466,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..873c607b90 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,12 @@ 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 drawn look a brushed stroke is kept by the object's art style, which is written + // only when the reader reads the brush back from it; a styled group's members are + // written again after the look, and warn there. + if !(self.in_look && s.brush.is_some()) { + self.warn(STROKES); + } self.wrote_fixed = true; if self.brush_art(path, s, xy) { return; 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..f370167bcf 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -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() }) } @@ -521,10 +550,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 c4ae9fda3b..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. @@ -889,9 +913,16 @@ pub(in crate::ai_editing) fn same_appearance(a: &Appearance, b: &Appearance) -> } /// Effect `e`'s settings with its defaults filled in, its blending mode in lower case and its color as -/// `#rrggbb`. +/// `#rrggbb`. A Warp's map `version` is left out: the file holds the warp's settings, and the reader +/// gives every warp it reads the newest map, so a warp saved before the version existed is written +/// with its settings as before. fn normalized_params(e: &Effect) -> Value { let mut p = vectorcraft_effects::merged_params(&e.id, &e.params); + if e.id.starts_with("warp.") + && let Value::Object(m) = &mut p + { + m.remove("version"); + } if let Some(m) = word(&p, "mode") { p["mode"] = Value::String(m); } @@ -911,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/import.rs b/crates/eps/src/import.rs index 0a5799ddf9..b750efbc20 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -42,6 +42,7 @@ mod interp; mod lex; mod native; mod obj; +mod predefined_cmap; mod preview; mod shading; mod text; @@ -85,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/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 8b3fceecf3..50674860a6 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -244,6 +244,7 @@ fn warp(o: &Obj) -> Option { h: percent("DeformHoriz")?, v: percent("DeformVert")?, horizontal: !vertical, + version: vectorcraft_doc::live::WARP_NET, }) } diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index a1b24662ec..6cdbb96747 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -14,12 +14,12 @@ use std::sync::Arc; -use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded}; use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen}; use vectorcraft_color::Paint; use vectorcraft_doc::{Burasagari, CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun}; use vectorcraft_geom::AnchorKind; +use super::super::super::predefined_cmap::{self, CMapCodes, composite_font}; use super::super::obj::V; use super::art::path_data; use super::{FLIP, GState, HashSet, Kind, Node, NodeId, Reader}; @@ -284,259 +284,15 @@ impl TypeState { /// How strings in a font with predefined CMap `cmap` decode; `None` when the CMap can't be read. fn cmap_codes(&mut self, cmap: &str) -> Option> { - if let Some(form) = unicode_cmap(cmap) { - return Some(Arc::new(CMapCodes::Unicode(form))); - } if let Some((_, codes)) = self.cmaps.iter().find(|(n, _)| n == cmap) { return codes.clone(); } - let codes = cid_codes(cmap).map(Arc::new); + let codes = predefined_cmap::codes(cmap).map(Arc::new); self.cmaps.push((cmap.to_string(), codes.clone())); codes } } -/// How a composite font's strings decode. -#[derive(Debug)] -enum CMapCodes { - /// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap; - /// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's - /// code space ([`code_space`]). - Cids { codes: Box, unicode: Box, high_bit: bool, space: Option }, - /// A predefined Unicode CMap: the codes are the characters. - Unicode(Unicode), -} - -/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the -/// code's length in bytes. -type CodeSpace = &'static [(&'static [u8], &'static [u8])]; - -/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab. -fn control(b: u8) -> Option { - match b { - b'\r' => Some('\n'), - b'\t' => Some('\t'), - _ => None, - } -} - -impl CMapCodes { - /// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a - /// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a - /// UTF-16 or UTF-32 string). - fn decode(&self, bytes: &[u8]) -> String { - let (codes, unicode, high_bit, space) = match self { - Self::Unicode(form) => { - let unit = match form { - Unicode::Utf16 => 2, - Unicode::Utf8 => 1, - Unicode::Utf32 => 4, - }; - let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[])); - let mut text = unicode_text(whole, *form).replace('\r', "\n"); - text.extend(rest.last().copied().and_then(control)); - return text; - } - Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space), - }; - let euc: Vec; - let bytes = if high_bit { - euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect(); - euc.as_slice() - } else { - bytes - }; - let mut out = String::new(); - let mut at = 0; - while let Some(&first) = bytes.get(at) { - // VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every - // CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space - // has no one-byte codes. - if let Some(control) = control(first) { - out.push(control); - at += 1; - continue; - } - let rest = bytes.get(at..).unwrap_or_default(); - let code = |len: u8| rest.get(..usize::from(len)).map(|b| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b))); - let found = match space { - // The code is as long as the code space range it falls in; a byte that starts no - // code in the code space is skipped. - Some(space) => { - let Some(len) = code_len(space, rest) else { - at += 1; - continue; - }; - code(len).map(|c| (len, codes.lookup_cid_code(c, len))) - } - None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))), - }; - let Some((len, cid)) = found else { - at += 1; - continue; - }; - at += usize::from(len); - // A code the CMap leaves out is dropped. - let Some(cid) = cid else { continue }; - match unicode.lookup_bf_string(cid) { - // A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in - // the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first. - Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => out.push(char::from(first)), - Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => out.push(c), - Some(BfString::String(s)) => out.extend(s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)), - _ => {} - } - } - out - } -} - -/// How a predefined Unicode CMap's codes encode their characters. -#[derive(Clone, Copy, PartialEq, Debug)] -enum Unicode { - /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. - Utf16, - Utf8, - /// Four bytes, the high byte first. - Utf32, -} - -/// 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 { - 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('-')?; - let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"]; - if !collections.contains(&collection) { - return None; - } - match form { - "UCS2" | "UTF16" => Some(Unicode::Utf16), - "UTF8" => Some(Unicode::Utf8), - "UTF32" => Some(Unicode::Utf32), - _ => None, - } -} - -/// The characters string `s` encodes in Unicode form `form` (invalid codes left out). -fn unicode_text(s: &[u8], form: Unicode) -> String { - match form { - Unicode::Utf16 => { - let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b)); - char::decode_utf16(units).filter_map(Result::ok).collect() - } - Unicode::Utf8 => s.utf8_chunks().map(|chunk| chunk.valid()).collect(), - Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(), - } -} - -/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose -/// codes are CIDs of no known character collection. -fn predefined(name: &str) -> Option> { - match CMapName::from_bytes(name.as_bytes()) { - CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None, - name => Some(name), - } -} - -/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V` -/// (): the nearest carried CMap with the same -/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC -/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space -/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads. -fn stand_in(stem: &str) -> Option> { - Some(match stem { - "RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)), - "78-EUC" => Some(("EUC-H", false)), - "78" | "Add" | "Ext" | "NWP" => Some(("H", false)), - "GB" => Some(("GB-EUC-H", true)), - "CNS1" => Some(("CNS-EUC-H", true)), - "KSC" => Some(("KSC-EUC-H", true)), - "B5" => Some(("ETen-B5-H", false)), - "ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)), - "GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None, - _ => return None, - }) -} - -/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A -/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two -/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with -/// its [`stand_in`]. -fn cid_codes(cmap: &str) -> Option { - let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded); - let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H")); - let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")); - let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| { - let (name, high_bit) = stand_in(stem?).flatten()?; - Some((read(name)?, high_bit)) - })?; - let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?; - let unicode = CMap::parse(load_embedded(table)?, load_embedded)?; - // A row-and-cell code read in its EUC form falls in the EUC CMap's code space. - let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) }; - Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space }) -} - -/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it -/// (); a CMap's vertical form has its -/// horizontal form's. `None` for a CMap not listed here. -fn code_space(cmap: &str) -> Option { - const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; - const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; - const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")]; - const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")]; - const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")]; - const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")]; - const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")]; - const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")]; - const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")]; - const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")]; - const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")]; - const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")]; - const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")]; - const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")]; - const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")]; - const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")]; - let stem = match cmap { - "H" | "V" => "", - _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?, - }; - let stem = stem.strip_suffix("-HW").unwrap_or(stem); - Some(match stem { - "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, - _ if stem.ends_with("RKSJ") => RKSJ, - "EUC" | "78-EUC" => EUC_JP, - "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, - "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, - "GBpc-EUC" | "GBTpc-EUC" => GB_PC, - "GBK-EUC" | "GBKp-EUC" => GBK, - "GBK2K" => GBK2K, - "B5pc" => B5_PC, - "B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5, - "HKscs-B5" | "ETHK-B5" => HKSCS, - "HKdlb-B5" => HKDLB, - "HKgccs-B5" => HKGCCS, - "CNS-EUC" => CNS_EUC, - "KSCpc-EUC" => KSC_PC, - "KSCms-UHC" => UHC, - _ => return None, - }) -} - -/// The length of the code string `bytes` starts with: the length of the code space range whose -/// lowest and highest codes hold each of its bytes between them. `None` when no range does. -fn code_len(space: CodeSpace, bytes: &[u8]) -> Option { - space - .iter() - .filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b)))) - .map(|(lo, _)| lo.len()) - .min() - .and_then(|len| u8::try_from(len).ok()) -} - /// Encoding pairs (§3.3.1): codes, each followed by the glyph names from it on, into `table`. fn encoding_pairs(table: &mut [Option], vals: &[V]) { let mut code: Option = None; @@ -556,36 +312,6 @@ fn encoding_pairs(table: &mut [Option], vals: &[V]) { } } -/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a -/// predefined Unicode CMap ([`unicode_cmap`]), one of the predefined CMaps VectorCraft carries -/// ([`predefined`]) or one of the others ([`stand_in`]), in its horizontal (`-H`) or vertical -/// (`-V`) form. A name ending in an Identity CMap is not read as one, nor is one that ends in an -/// unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise end in `H`. -fn composite_font(name: &str) -> Option<(&str, &str)> { - if !(name.ends_with("-H") || name.ends_with("-V")) { - return None; - } - // The longest CMap name first: `83pv-RKSJ-H` before `H`. - // No predefined CMap name is longer than 32 bytes. - for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) { - let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue }; - if matches!(cmap, "Identity-H" | "Identity-V") { - return None; - } - let encoding_part = - base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part)); - if matches!(cmap, "H" | "V") && encoding_part { - return None; - } - let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some()); - let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some()); - if unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() { - return Some((base, cmap)); - } - } - None -} - /// An operator's `N` operands, the last `N` values before it, when each is a finite number. fn nums(vals: &[V]) -> Option<[f64; N]> { let operands = vals.get(vals.len().checked_sub(N)?..)?; diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index f7df4f35d6..911d8340cc 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1259,7 +1259,7 @@ mod tests { fn envelopes_in_symbol_definitions_spend_the_imports_checks() { use crate::import::ai::tests::{drawn_content, envelope_content, envelope_of, straight_mesh, warp_keys}; use vectorcraft_doc::live::EnvelopeKind; - let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: vectorcraft_doc::live::WARP_NET }; let envelope = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&want)); let mut reader = Reader::new(b""); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index c0d9852d88..634c6ae9ca 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1237,8 +1237,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 +1253,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 f15744f30c..ef661c1446 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -1159,6 +1159,7 @@ fn effect(f: &Filter, under: Under) -> Option { "horizontal": pct("DeformHoriz"), "vertical": pct("DeformVert"), "orientation": if flag("Rotate") { "vertical" } else { "horizontal" }, + "version": vectorcraft_doc::live::WARP_NET, }), ) } diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index d39a87b5c0..47086ca171 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -495,6 +495,8 @@ fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() { // The warp is the whole object's; the characters paint below every item. let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) }; assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal"))); + // A file's warp bends as its style's Bézier net. + assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET)); assert_eq!(st.contents_index, None); } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 46386d2200..8fa0a58346 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use kurbo::{Affine, Point, Rect}; use vectorcraft_color::gradient::GradientKind; use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::live::WARP_NET; use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit}; use super::*; @@ -7403,15 +7404,16 @@ fn a_warp_envelope_opens_live() { for (style, bend, h, v, rotate, id) in [(1, 0.5, 0.0, 0.0, 0, "arc"), (1, 0.5, -0.2, 0.3, 1, "arc"), (8, 0.5, 0.0, 0.0, 0, "flag"), (15, 0.5, 0.0, 0.0, 0, "twist")] { - let want = EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0 }; + let want = + EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0, version: WARP_NET }; let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(style, bend, h, v, rotate), &envelope_content(), 1, &drawn_content(&want)); let r = read_ok(&layer(&body)); let a = art(&r.doc); assert_eq!(a.len(), 1, "{a:#?}"); let (content, kind, fidelity, editing, options) = live_envelope(&a[0]); - let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal } = kind else { panic!("{kind:?}") }; - assert_eq!((s.as_str(), *horizontal), (id, rotate == 0)); + let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal, version } = kind else { panic!("{kind:?}") }; + assert_eq!((s.as_str(), *horizontal, *version), (id, rotate == 0, WARP_NET)); assert!([(*b, bend), (*dh, h), (*dv, v)].iter().all(|(got, f)| (got - f * 100.0).abs() < 1e-9), "{kind:?}"); assert_eq!((fidelity, editing, options), (50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW)); assert_eq!(content.len(), 1, "{content:#?}"); @@ -7443,7 +7445,7 @@ fn warp_envelopes_read_every_style() { "twist", ]; for (n, id) in ids.iter().enumerate() { - let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; let keys = warp_keys(n as u32 + 1, 0.3, 0.0, 0.0, 0); let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 1, &drawn_content(&want)))); let a = art(&r.doc); @@ -7974,7 +7976,7 @@ fn an_envelope_that_doesnt_read_keeps_its_drawn_art() { #[test] fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { use vectorcraft_doc::live::EnvelopeKind; - let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true }; + let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&other)); let r = read_ok(&layer(&body)); let a = art(&r.doc); @@ -7989,7 +7991,7 @@ fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { fn a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { use vectorcraft_doc::live::EnvelopeKind; const WHITE: &str = "0 0 0 0 1 1 1 Xa\n"; - let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true }; + let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; let content = envelope_content().replace(RED, WHITE); let drawn = drawn_content(&other).replace(RED, WHITE); let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &content, 1, &drawn); diff --git a/crates/eps/src/import/cid.rs b/crates/eps/src/import/cid.rs index e2ad169f79..4c00486de9 100644 --- a/crates/eps/src/import/cid.rs +++ b/crates/eps/src/import/cid.rs @@ -210,54 +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))) } -/// How a predefined Unicode CMap's codes encode their characters. -#[derive(Clone, Copy, PartialEq, Debug)] -pub(super) enum Unicode { - /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. - Utf16, - Utf8, - /// Four bytes, the high byte first. - Utf32, -} - -/// 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)) -} - -/// The characters show string `s` encodes in Unicode form `form` (invalid codes left out). -pub(super) fn unicode_text(s: &[u8], form: Unicode) -> String { - match form { - Unicode::Utf16 => { - let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b)); - char::decode_utf16(units).filter_map(Result::ok).collect() - } - Unicode::Utf8 => String::from_utf8_lossy(s).chars().filter(|c| *c != char::REPLACEMENT_CHARACTER).collect(), - Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(), - } -} - /// 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 { @@ -417,8 +369,11 @@ impl Interp<'_> { return Ok(d.clone()); } let text = name.text().map(|t| t.to_string()).unwrap_or_default(); - match unicode_cmap(&text) { - Some((_, vertical)) => { + // 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(); d.insert(Key::name("CMapName"), Obj::name(&text)); d.insert(Key::name("CMapType"), Obj::Int(1)); diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 992a8bbcea..6b96aabc25 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -92,6 +92,12 @@ pub(crate) struct Interp<'a> { pub cid_subrs: super::cid::CidSubrs, /// The font number `usefont` gave the CMap mappings that follow. pub cmap_font: i64, + /// 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, pub g: GState, pub saved: Vec, pub out: Out, @@ -232,6 +238,8 @@ impl<'a> Interp<'a> { cff: vec![], cid_subrs: vec![], cmap_font: 0, + predefined_cmaps: vec![], + kshow_font: None, type42_counted: HashSet::new(), g, saved: vec![], diff --git a/crates/eps/src/import/predefined_cmap.rs b/crates/eps/src/import/predefined_cmap.rs new file mode 100644 index 0000000000..19525fdf36 --- /dev/null +++ b/crates/eps/src/import/predefined_cmap.rs @@ -0,0 +1,380 @@ +//! Strings in fonts over Adobe's predefined CMaps () +//! read as characters: the codes of a predefined Unicode CMap are the characters; those of the +//! others map to CIDs of a character collection, whose CID → Unicode CMap gives the characters. +//! Used by legacy `.ai` type in composite fonts and by type in CID-keyed fonts found by name. + +use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded}; + +/// How a composite font's strings decode. +#[derive(Debug)] +pub(super) enum CMapCodes { + /// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap; + /// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's + /// code space ([`code_space`]). + Cids { codes: Box, unicode: Box, high_bit: bool, space: Option }, + /// A predefined Unicode CMap: the codes are the characters. + Unicode(Unicode), +} + +/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the +/// code's length in bytes. +type CodeSpace = &'static [(&'static [u8], &'static [u8])]; + +/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab. +fn control(b: u8) -> Option { + match b { + b'\r' => Some('\n'), + b'\t' => Some('\t'), + _ => None, + } +} + +impl CMapCodes { + /// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a + /// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a + /// UTF-16 or UTF-32 string). + pub(super) fn decode(&self, bytes: &[u8]) -> String { + let mut text = String::new(); + self.each_code(bytes, |_, chars| text.extend(chars)); + text + } + + /// The codes of string `bytes`, each with its value and the characters it stands for (as + /// [`Self::decode`] reads them; none for a code the CMaps don't map, or for a byte that starts + /// no code, which counts as a code of its own). + pub(super) fn codes(&self, bytes: &[u8]) -> Decoded { + let mut out = Decoded::default(); + self.each_code(bytes, |code, chars| out.push(code, chars)); + out + } + + /// Calls `emit` with each code of string `bytes` in turn ([`Self::codes`]): its value and the + /// characters it stands for. + fn each_code(&self, bytes: &[u8], mut emit: impl FnMut(u32, &mut dyn Iterator)) { + let value = |b: &[u8]| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b)); + let (codes, unicode, high_bit, space) = match self { + Self::Unicode(form) => { + let unit = match form { + Unicode::Utf16 => 2, + Unicode::Utf8 => 1, + Unicode::Utf32 => 4, + }; + let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[])); + unicode_codes(whole, *form, |code, c| emit(value(code), &mut c.map(|c| if c == '\r' { '\n' } else { c }).into_iter())); + if let Some(&last) = rest.last() { + emit(value(rest), &mut control(last).into_iter()); + } + return; + } + Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space), + }; + // A code's value is that of the string's bytes, also where they're read in their EUC form. + let original = bytes; + let value_at = |at: usize, len: usize| original.get(at..at + len).map_or(0, value); + let euc: Vec; + let bytes = if high_bit { + euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect(); + euc.as_slice() + } else { + bytes + }; + let mut at = 0; + while let Some(&first) = bytes.get(at) { + // VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every + // CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space + // has no one-byte codes. + if let Some(control) = control(first) { + emit(value_at(at, 1), &mut Some(control).into_iter()); + at += 1; + continue; + } + let rest = bytes.get(at..).unwrap_or_default(); + let code = |len: u8| rest.get(..usize::from(len)).map(value); + let found = match space { + // The code is as long as the code space range it falls in; a byte that starts no + // code in the code space is skipped. + Some(space) => code_len(space, rest).and_then(|len| code(len).map(|c| (len, codes.lookup_cid_code(c, len)))), + None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))), + }; + let Some((len, cid)) = found else { + emit(value_at(at, 1), &mut None.into_iter()); + at += 1; + continue; + }; + let value = value_at(at, usize::from(len)); + at += usize::from(len); + // A code the CMap leaves out stands for no character. + match cid.and_then(|cid| unicode.lookup_bf_string(cid)) { + // A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in + // the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first. + Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => { + emit(value, &mut Some(char::from(first)).into_iter()) + } + Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => emit(value, &mut Some(c).into_iter()), + Some(BfString::String(s)) => emit(value, &mut s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)), + _ => emit(value, &mut None.into_iter()), + } + } + } +} + +/// A string's characters, and its codes: each code's value and how many of the characters it +/// stands for, in order. +#[derive(Debug, Default)] +pub(super) struct Decoded { + pub text: String, + pub codes: Vec<(u32, usize)>, +} + +impl Decoded { + /// Code `code`, standing for characters `chars`. + pub(super) fn push(&mut self, code: u32, chars: impl IntoIterator) { + let before = self.text.len(); + self.text.extend(chars); + let n = self.text.get(before..).map_or(0, |added| added.chars().count()); + self.codes.push((code, n)); + } +} + +/// How strings in a font over predefined CMap `cmap` decode; `None` when the CMap can't be read. +pub(super) fn codes(cmap: &str) -> Option { + match unicode_cmap(cmap) { + Some(form) => Some(CMapCodes::Unicode(form)), + None => cid_codes(cmap), + } +} + +/// How a predefined Unicode CMap's codes encode their characters. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(super) enum Unicode { + /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. + Utf16, + Utf8, + /// Four bytes, the high byte first. + Utf32, +} + +/// 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`: ). +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('-')?; + let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"]; + if !collections.contains(&collection) { + return None; + } + match form { + "UCS2" | "UTF16" => Some(Unicode::Utf16), + "UTF8" => Some(Unicode::Utf8), + "UTF32" => Some(Unicode::Utf32), + _ => None, + } +} + +/// Calls `out` with each code of string `s` in Unicode form `form` in turn, and the character it +/// encodes (none for an invalid code: a lone surrogate, a byte that starts no UTF-8 character, a +/// value past U+10FFFF). +fn unicode_codes(s: &[u8], form: Unicode, mut out: impl FnMut(&[u8], Option)) { + match form { + Unicode::Utf16 => { + let unit = |i: usize| s.get(i..i + 2).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(u16::from_be_bytes); + let mut at = 0; + while let Some(u) = unit(at) { + let next = unit(at + 2); + let pair = next.filter(|n| (0xd800..0xdc00).contains(&u) && (0xdc00..0xe000).contains(n)); + let len = if pair.is_some() { 4 } else { 2 }; + let c = char::decode_utf16([u].into_iter().chain(pair)).next().and_then(Result::ok); + out(s.get(at..at + len).unwrap_or_default(), c); + at += len; + } + } + Unicode::Utf8 => { + let mut at = 0; + for chunk in s.utf8_chunks() { + for c in chunk.valid().chars() { + out(s.get(at..at + c.len_utf8()).unwrap_or_default(), Some(c)); + at += c.len_utf8(); + } + for _ in chunk.invalid() { + out(s.get(at..at + 1).unwrap_or_default(), None); + at += 1; + } + } + } + Unicode::Utf32 => { + for b in s.as_chunks::<4>().0 { + out(b.as_slice(), char::from_u32(u32::from_be_bytes(*b))); + } + } + } +} + +/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose +/// codes are CIDs of no known character collection. +fn predefined(name: &str) -> Option> { + match CMapName::from_bytes(name.as_bytes()) { + CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None, + name => Some(name), + } +} + +/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V` +/// (): the nearest carried CMap with the same +/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC +/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space +/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads. +fn stand_in(stem: &str) -> Option> { + Some(match stem { + "RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)), + "78-EUC" => Some(("EUC-H", false)), + "78" | "Add" | "Ext" | "NWP" => Some(("H", false)), + "GB" => Some(("GB-EUC-H", true)), + "CNS1" => Some(("CNS-EUC-H", true)), + "KSC" => Some(("KSC-EUC-H", true)), + "B5" => Some(("ETen-B5-H", false)), + "ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)), + "GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None, + _ => return None, + }) +} + +/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A +/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two +/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with +/// its [`stand_in`]. +fn cid_codes(cmap: &str) -> Option { + let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded); + let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H")); + let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")); + let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| { + let (name, high_bit) = stand_in(stem?).flatten()?; + Some((read(name)?, high_bit)) + })?; + let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?; + let unicode = CMap::parse(load_embedded(table)?, load_embedded)?; + // A row-and-cell code read in its EUC form falls in the EUC CMap's code space. + let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) }; + Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space }) +} + +/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it +/// (); a CMap's vertical form has its +/// horizontal form's. `None` for a CMap not listed here. +fn code_space(cmap: &str) -> Option { + const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; + const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; + const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")]; + const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")]; + const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")]; + const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")]; + const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")]; + const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")]; + const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")]; + const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")]; + const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")]; + const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")]; + const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")]; + const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")]; + const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")]; + const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")]; + let stem = match cmap { + "H" | "V" => "", + _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?, + }; + let stem = stem.strip_suffix("-HW").unwrap_or(stem); + Some(match stem { + "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, + _ if stem.ends_with("RKSJ") => RKSJ, + "EUC" | "78-EUC" => EUC_JP, + "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, + "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, + "GBpc-EUC" | "GBTpc-EUC" => GB_PC, + "GBK-EUC" | "GBKp-EUC" => GBK, + "GBK2K" => GBK2K, + "B5pc" => B5_PC, + "B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5, + "HKscs-B5" | "ETHK-B5" => HKSCS, + "HKdlb-B5" => HKDLB, + "HKgccs-B5" => HKGCCS, + "CNS-EUC" => CNS_EUC, + "KSCpc-EUC" => KSC_PC, + "KSCms-UHC" => UHC, + _ => return None, + }) +} + +/// The length of the code string `bytes` starts with: the length of the code space range whose +/// lowest and highest codes hold each of its bytes between them. `None` when no range does. +fn code_len(space: CodeSpace, bytes: &[u8]) -> Option { + space + .iter() + .filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b)))) + .map(|(lo, _)| lo.len()) + .min() + .and_then(|len| u8::try_from(len).ok()) +} + +/// Whether `cmap` names a predefined CMap: a predefined Unicode CMap ([`unicode_cmap`]), one of the +/// predefined CMaps VectorCraft carries ([`predefined`]) or one of the others ([`stand_in`]), in +/// its horizontal (`-H`) or vertical (`-V`) form. The Identity CMaps are not among them. +pub(super) fn known(cmap: &str) -> bool { + let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some()); + let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some()); + 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), or two bytes when the code space isn't listed (the +/// row-and-cell codes of `GBT-H` and `CNS2-H`). 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") +} + +/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a +/// predefined CMap ([`known`]). A name ending in an Identity CMap is not read as one, nor is one +/// that ends in an unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise +/// end in `H`. +pub(super) fn composite_font(name: &str) -> Option<(&str, &str)> { + if !(name.ends_with("-H") || name.ends_with("-V")) { + return None; + } + // The longest CMap name first: `83pv-RKSJ-H` before `H`. + // No predefined CMap name is longer than 32 bytes. + for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) { + let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue }; + if matches!(cmap, "Identity-H" | "Identity-V") { + return None; + } + let encoding_part = + base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part)); + if matches!(cmap, "H" | "V") && encoding_part { + return None; + } + if known(cmap) { + return Some((base, cmap)); + } + } + None +} diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 2b01b2b4f2..25ed81c2b0 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -403,6 +403,263 @@ fn named_cidfonts_over_unicode_cmaps_set_their_characters_as_type() { check("/Ryumin-Light-UniJIS-UCS2-H findfont 20 scalefont setfont <00410042> stringwidth pop dup 5 gt exch 40 lt and"); } +/// The text objects of an import. +fn texts(r: &crate::Imported) -> Vec { + all(&r.document) + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some((**t).clone()), + _ => None, + }) + .collect() +} + +/// A CID-keyed font over one of the other predefined CMaps (Shift-JIS, EUC, GBK, Big Five, UHC, +/// JIS row and cell: ), the file defining +/// neither, its CIDFont only named: the codes are read by that CMap and its character +/// collection's CIDs to Unicode, and set as type in the font of the CIDFont's name, whether +/// found as `CIDFont-CMap` or composed. Codes the CMap doesn't map and control codes are left out. +#[test] +fn named_cidfonts_over_predefined_cmaps_set_their_characters_as_type() { + let cases: &[(&str, &str, &str)] = &[ + // 日 is 0x93FA and 本 0x967B in Shift-JIS. + ("83pv-RKSJ-H", "93fa967b41", "日本A"), + ("90ms-RKSJ-H", "93fa", "日"), + ("EUC-H", "c6fc", "日"), + ("GBK-EUC-H", "d6d0", "中"), + ("B5pc-H", "a4a4", "中"), + ("ETen-B5-H", "a4a4", "中"), + ("KSCms-UHC-H", "c7d1", "한"), + // A CMap read with the nearest carried one: GB 2312 row and cell in EUC form, 中 0x5650. + ("GB-H", "5650", "中"), + // 0x8540 maps to no CID, 0xFD is in no code space range, 0x0D is a control code. + ("90ms-RKSJ-H", "854093fa41fd0d42", "日AB"), + ]; + for (cmap, hex, want) in cases { + let r = super::tests::read(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont 10 20 moveto <{hex}> show")); + assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{cmap}: {:?}", r.warnings); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), [*want], "{cmap}"); + let style = &t[0].runs[0].style; + assert_eq!((style.font_family.as_str(), style.font_style.as_str(), style.size), ("Ryumin", "Light", 20.0), "{cmap}"); + assert!(!t[0].vertical, "{cmap}"); + } + 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"); +} + +/// Type in a CID-keyed font over a predefined CMap, its CIDFont only named, shown with `ashow`, +/// `widthshow` (matching a code by its value) and `xshow`, is spaced as the operator says: the +/// point moves that far, and the characters' tracking makes up the difference, as for type in a +/// font found by name. Over a vertical CMap the extra space runs down the column. +#[test] +fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() { + // Measured without painting. + let measured = |body: &str| check(&format!("gsave nulldevice {body} grestore")); + let set = "/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont"; + // `ashow` adds 5 to each of the two codes. + measured(&format!( + "{set} 10 20 moveto <93fa967b> show currentpoint pop 10 20 moveto 5 0 <93fa967b> ashow currentpoint pop sub -10 sub abs 0.01 lt" + )); + let r = super::tests::read(&format!("{set} 10 20 moveto 5 0 <93fa967b> ashow")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["日本"]); + assert!(t[0].runs.iter().all(|run| (run.style.tracking - 250.0).abs() < 0.5), "{:?}", t[0].runs); + // `widthshow` adds 7 to the space, code 0x20. + measured(&format!( + "{set} 10 20 moveto <93fa20967b> show currentpoint pop 10 20 moveto 7 0 32 <93fa20967b> widthshow currentpoint pop sub -7 sub abs 0.01 lt" + )); + // A row-and-cell code read in its EUC form keeps the value of the string's bytes: 中 0x5650. + let gb = "/STSong-Light-GB-H findfont 20 scalefont setfont"; + 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" + )); + // `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, and a + // font it sets stays current. + 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 /Helvetica eq" + )); + // The procedure sees the font the file set as current, and the next one the font an earlier + // one set. + 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 /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!( + "{set} 10 200 moveto <93fa967b> show currentpoint exch pop 10 200 moveto 0 -5 <93fa967b> ashow currentpoint exch pop sub 10 sub abs 0.01 lt" + )); + let r = super::tests::read(&format!("{set} 10 200 moveto 0 -5 <93fa967b> ashow")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::(), "日本"); + assert!(t.iter().all(|t| t.vertical), "{t:?}"); +} + +/// A CID-keyed font over a vertical predefined CMap (`-V`), the file defining neither, its CIDFont +/// only named: the type is a vertical column, and the point moves down it, not along a line. +#[test] +fn named_cidfonts_over_vertical_cmaps_set_vertical_type() { + for (cmap, hex) in [("90ms-RKSJ-V", "93fa967b"), ("UniJIS-UCS2-V", "65e5672c")] { + let r = super::tests::read(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont 10 20 moveto <{hex}> show")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["日本"], "{cmap}: {:?}", r.warnings); + assert!(t[0].vertical, "{cmap}"); + check(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont <{hex}> stringwidth dup -60 gt exch -5 lt and exch abs 0.01 lt and")); + } +} + +/// Down a column each code of a CID-keyed font over a vertical predefined CMap moves the point one +/// em, the default vertical displacement (PDF Reference 1.7, §5.6.3, `DW2`), also two digits that +/// vertical type sets side by side in one em: `show` and `stringwidth` move the point as far as +/// showing the codes one at a time. +#[test] +fn codes_down_a_column_each_move_the_point_one_em() { + // Measured without painting. + let set = "gsave nulldevice /Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont"; + check(&format!("{set} (12) stringwidth grestore -40 sub abs 0.01 lt exch abs 0.01 lt and")); + check(&format!("{set} 10 100 moveto (12) show currentpoint grestore 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and")); + check(&format!( + "{set} 10 100 moveto (1) show (2) show currentpoint 10 100 moveto (12) show currentpoint grestore \ + 3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and" + )); +} + +/// Type in a CID-keyed font over a predefined CMap is set the same way by `show` and by the show +/// operators that space it: characters the font would join in a ligature (`fi` in the bundled +/// Source Serif 4) are set without the ligature, and the point moves as far. +#[test] +fn named_cid_type_is_set_alike_by_every_show_operator() { + let set = "/SourceSerif4-Regular-UniJIS-UCS2-H findfont 50 scalefont setfont 10 20 moveto"; + let features = |show: &str| { + 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}"); + // 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"); + 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 \ + 20 sub 3 -1 roll sub abs 0.01 lt 3 1 roll 10 sub sub abs 0.01 lt and" + )); +} + +/// `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 (two bytes for a two-byte code) with its +/// width; `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: 日 (0x93FA), then A. + check(&format!("{set} [ {{pop pop}} <93fa41> cshow ] dup length 2 eq exch dup 0 get 16#93fa 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")); + 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() { + 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", + ] { + 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"); +} + +/// A string in the file, in a CID-keyed font over a predefined CMap, too long to decode within the +/// memory budget stops the program with an error before it is decoded. +#[test] +fn huge_strings_over_predefined_cmaps_count_against_the_memory_budget() { + // Codes the CMap doesn't map, so nothing is laid out when the string is decoded. + let huge = "\u{1}".repeat(17 << 20); + let r = import(&super::tests::eps(&format!( + "0 0 10 10 rectfill /Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont 10 20 moveto ({huge}) stringwidth" + ))) + .unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings); +} + /// A Type 2 CIDFont the file carries (its glyph space one unit an em), over a Unicode CMap the /// file doesn't define, sets its characters as type at the font's size, not a thousand times it. #[test] diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 8f64280367..7353c5c190 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5419,7 +5419,14 @@ fn type_envelope_text(family: &str, paint: Option<&str>) -> String { fn arc_envelope(text: &Node, bend: f64) -> Node { let mut text = text.clone(); text.visible = true; - let kind = vectorcraft_doc::live::EnvelopeKind::Warp { style: "arc".into(), bend, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::live::EnvelopeKind::Warp { + style: "arc".into(), + bend, + h: 0.0, + v: 0.0, + horizontal: true, + version: vectorcraft_doc::live::WARP_NET, + }; Node::new( vectorcraft_doc::NodeId(1_000_000), NodeKind::Envelope { diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 5868aa9228..73fc4776d0 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -15,6 +15,7 @@ use super::cid::Base; use super::graphics::MAX_APART; use super::interp::{Interp, matrix_obj, matrix_of}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err}; +use super::predefined_cmap::Decoded; use super::type1::Embedded; /// The font a program uses before it sets one. @@ -170,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((text, font)) = self.unicode_type(&s)? { - self.show_unicode(&text, &font)?; + 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 { @@ -185,6 +186,23 @@ 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 gets each + // code's value. + if let Some(named) = self.named_cid_type(&s)? { + let mut chars = named.codes.text.chars(); + for &(code, n) in &named.codes.codes { + let mut one = Decoded::default(); + one.push(code, chars.by_ref().take(n)); + let v = self.named_cid_width(&named, &one)?; + self.push(Obj::Int(i64::from(code)))?; + self.push_num(v.x)?; + self.push_num(v.y)?; + if !self.body(&proc)? { + break; + } + } + return Ok(()); + } for b in s { let v = self.width_of(&[b])?; self.push(Obj::Int(i64::from(b)))?; @@ -207,7 +225,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())?; + } } } } @@ -282,11 +307,8 @@ impl Interp<'_> { /// How far `s` moves the current point (user space). fn width_of(&mut self, s: &[u8]) -> Res { - if let Some((text, font)) = self.unicode_type(s)? { - let saved = self.g.font.replace(font); - let r = self.set_text(&text, Point::ZERO).map(|t| t.2); - 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; @@ -395,18 +417,27 @@ impl Interp<'_> { } /// The font named `k`: one the program defined; else, for a name `CIDFont-CMap` whose CMap - /// is a predefined Unicode 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); else a font of - /// that name. + /// 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 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.match_indices('-').map(|(i, _)| i).find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()); - if let Some((cid_font, cmap)) = split.and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) { + let unicode = text + .match_indices('-') + .map(|(i, _)| i) + .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))?; return Ok(self.composed(key, cmap, vec![Obj::Dict(dep)])); @@ -422,6 +453,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() { @@ -476,6 +533,15 @@ impl Interp<'_> { /// [`Self::set_type`] for the characters `text`. fn set_text(&mut self, text: &str, at: Point) -> Res<(TextObject, TextLayout, Vec2)> { + self.set_text_in(text, at, false) + } + + /// [`Self::set_text`], as vertical type when `vertical`: a column anchored at `at`, the top + /// center of its first em box, that moves the current point down the column by the layout's + /// advances. In vertical writing the current point is a glyph's origin 1, by default half its + /// width across and 880 units of 1000 above its origin 0 (PDF Reference 1.7, §5.6.3 "Glyph + /// Metrics in CIDFonts", `DW2`). + fn set_text_in(&mut self, text: &str, at: Point, vertical: bool) -> Res<(TextObject, TextLayout, Vec2)> { // Laying out type costs about as much as 300 operations, and 64 more a character // (measured), so `{ (a) stringwidth pop pop } loop` or `cshow` on a long string ends // within the budget like any other loop instead of running for minutes. @@ -493,11 +559,29 @@ impl Interp<'_> { let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() }; let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() }; let mut t = TextObject::point(Point::ZERO, text, style); + t.vertical = vertical; let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); t.xf = m * Affine::scale_non_uniform(1.0 / size, -1.0 / size); t.cached_bounds = Some(layout.bounds); let adv: f64 = layout.glyphs.iter().map(|g| g.advance).sum(); - Ok((t, layout, f1 * Point::new(adv / size, 0.0) - f1 * Point::ZERO)) + let moved = if vertical { Point::new(0.0, -adv / size) } else { Point::new(adv / size, 0.0) }; + Ok((t, layout, f1 * moved - f1 * Point::ZERO)) + } + + /// [`Self::set_text_in`] for characters the file places one by one: where the layout joins + /// characters in a ligature, the type is set without ligatures, so each character has its own + /// advance. + fn set_placed_text(&mut self, text: &str, at: Point, vertical: bool) -> Res<(TextObject, TextLayout)> { + let (mut t, mut layout, _) = self.set_text_in(text, at, vertical)?; + let glyph_starts: std::collections::HashSet = layout.glyphs.iter().map(|g| g.byte).collect(); + if text.char_indices().any(|(b, _)| !glyph_starts.contains(&b)) { + for run in &mut t.runs { + run.style.features.push("-liga".into()); + } + layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + t.cached_bounds = Some(layout.bounds); + } + Ok((t, layout)) } /// `show`: point type at the current point, which moves past it. @@ -527,27 +611,40 @@ impl Interp<'_> { return self.show_type42_outlines(data, glyphs, s, spacing, at, f1); } // Each code's character, if it has one that shows (control codes don't). - let chars: Vec> = match &plain { + let chars = match &plain { Some((_, glyphs)) => glyphs.iter().map(|g| g.ch).collect::>(), None => self.named_chars(s).into_iter().map(Some).collect(), + }; + let mut codes = Decoded::default(); + for (c, &code) in chars.into_iter().zip(s) { + codes.push(u32::from(code), c.filter(|c| !c.is_control())); } - .into_iter() - .map(|c| c.filter(|c| !c.is_control())) - .collect(); - let text: String = chars.iter().flatten().collect(); - let (mut t, mut layout, _) = self.set_text(&text, at)?; + let widths: Option> = plain.map(|(_, glyphs)| glyphs.iter().map(|g| g.width).collect()); + 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` 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; + let (mut t, layout) = self.set_placed_text(text, at, vertical)?; let size = t.runs.first().map_or(1.0, |r| r.style.size); let starts: Vec = text.char_indices().map(|(b, _)| b).collect(); - // The file places each character's glyph: where the layout joins characters in a - // ligature, the type is set without ligatures, so each character has its own advance. - let glyph_starts: std::collections::HashSet = layout.glyphs.iter().map(|g| g.byte).collect(); - if starts.iter().any(|b| !glyph_starts.contains(b)) { - for run in &mut t.runs { - run.style.features.push("-liga".into()); - } - layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - t.cached_bounds = Some(layout.bounds); - } // Each character's advance in the layout (text space). let mut natural = vec![0.0; starts.len()]; for g in &layout.glyphs { @@ -555,48 +652,53 @@ impl Interp<'_> { *n += g.advance; } } - // Each code's move (user space) and that move in text space, along the baseline and across. - let em = |x: f64| f1 * Point::new(x, 0.0) - f1 * Point::ZERO; + // Each code's move (user space) and that move in text space, along the baseline (down the + // column) and across. + let em = |x: f64| f1 * if vertical { Point::new(0.0, -x) } else { Point::new(x, 0.0) } - f1 * Point::ZERO; let inv = f1.inverse(); - let mut moves = Vec::with_capacity(s.len()); + let mut moves = Vec::with_capacity(codes.codes.len()); let mut k = 0; // What `kshow`'s procedure draws goes after the type. let start = self.out.drawn.len(); let mut pen = at; - for (i, (&code, c)) in s.iter().zip(&chars).enumerate() { - let own = match &plain { - Some((_, glyphs)) => glyphs.get(i).map_or(0.0, |g| g.width), - None if c.is_some() => natural.get(k).copied().unwrap_or(0.0) / size, - None => 0.0, + for (i, &(code, n)) in codes.codes.iter().enumerate() { + let own = match widths { + Some(widths) => widths.get(i).copied().unwrap_or(0.0), + None => natural.get(k..k + n).map_or(0.0, |w| w.iter().sum::()) / size, }; - k += usize::from(c.is_some()); + k += n; let mut d = spacing.advance(i, i64::from(code), em(own)); - d += self.kern(spacing, s, i, pen + d)?; + d += self.kern(spacing, kern, i, pen + d)?; pen += d; let e = inv * Point::new(d.x, d.y) - inv * Point::ZERO; - if ![d.x, d.y, e.x * size, e.y * size].iter().all(|v| v.is_finite()) { + let (along, across) = if vertical { (-e.y * size, e.x * size) } else { (e.x * size, e.y * size) }; + if ![d.x, d.y, along, across].iter().all(|v| v.is_finite()) { return ps_err("undefinedresult", "a show"); } - moves.push((d, e.x * size, e.y * size)); + moves.push((d, along, across)); } let total: Vec2 = moves.iter().map(|m| m.0).sum(); if !(total.x.is_finite() && total.y.is_finite()) { 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; - for ((d, ..), c) in moves.iter().zip(&chars) { - if let Some(c) = c - && painted - && !c.is_whitespace() - { - let (t, ..) = self.set_text(c.encode_utf8(&mut [0; 4]), pen)?; - let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); + let mut chars = text.chars(); + for ((d, ..), &(_, n)) in moves.iter().zip(&codes.codes) { + let c: String = chars.by_ref().take(n).collect(); + if painted && !c.trim().is_empty() { + let (t, ..) = self.set_text_in(&c, pen, vertical)?; + 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; } @@ -605,11 +707,17 @@ impl Interp<'_> { // a character, before the next, are that character's. let mut lead = Vec2::ZERO; let mut target: Vec = Vec::with_capacity(natural.len()); - for ((d, along, _), c) in moves.iter().zip(&chars) { - match (c, target.last_mut()) { - (Some(_), _) => target.push(*along), - (None, Some(last)) => *last += along, - (None, None) => lead += *d, + for ((d, along, _), &(_, n)) in moves.iter().zip(&codes.codes) { + if n > 0 { + // A code of several characters: those before its last keep their advances. + let before = natural.get(target.len()..target.len() + n - 1).unwrap_or_default().to_vec(); + let rest = *along - before.iter().sum::(); + target.extend(before); + target.push(rest); + } else if let Some(last) = target.last_mut() { + *last += along; + } else { + lead += *d; } } let tracking: Vec = target @@ -638,27 +746,64 @@ 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; a font it sets stays current. + let Some(outer) = self.kshow_font.take() else { + self.call(p.clone())?; + return Ok(self.user_point()? - pen); + }; + let inner = self.g.font.replace(outer.clone()); + let r = self.call(p.clone()); + // Once the procedure sets a font, it and the later procedures see that one. + if self.g.font.as_ref().is_some_and(|f| f.same(&outer)) { + self.g.font = inner; + self.kshow_font = Some(outer); + } + r?; Ok(self.user_point()? - pen) } @@ -709,15 +854,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()?; @@ -743,10 +887,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(()) @@ -807,6 +950,16 @@ 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, +} + /// What drawing a glyph does with it. #[derive(Clone, Copy, PartialEq)] enum Draw { @@ -931,23 +1084,55 @@ impl Interp<'_> { .collect()) } - /// The characters of `s` and the font to set them in when the current font is a CID-keyed - /// font over a predefined Unicode CMap the file doesn't define (`UniJIS-UCS2-H`), whose - /// mappings to CIDs aren't known: its codes say the characters, set as type in the font of - /// its CIDFont's name. - fn unicode_type(&mut self, s: &[u8]) -> Res> { - let Some((text, font, name)) = self.unicode_parts(s) else { return Ok(None) }; + /// The codes of `s` with the characters each stands for, the font to set them in and whether + /// they run down a column, when the current font is a CID-keyed font over a predefined CMap + /// the file doesn't define (`UniJIS-UCS2-H`, `90ms-RKSJ-H`), whose mappings to CIDs the file + /// doesn't give: a Unicode CMap's codes are the characters, another CMap's are read by the CMap + /// 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, each of its codes moving the point one em. + fn named_cid_type(&mut self, s: &[u8]) -> Res> { + 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 + // the memory budget first, so a huge string in the file stops the program with an error. + let held = s.len().saturating_mul(DECODED_BYTES); + self.alloc(held)?; + self.unalloc(held); + let (decoded, read) = match self.predefined_codes(&cmap) { + Some(codes) => (codes.codes(s), true), + None => { + self.out.warn(UNREAD_CMAP); + (super::predefined_cmap::unread_codes(&cmap, s), false) + } + }; + let Some(mut named) = self.named_cid_font()? else { return Ok(None) }; + let mut chars = decoded.text.chars(); + for &(code, n) in &decoded.codes { + 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> { + 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((text, DictRef::new(named)))) + Ok(Some(NamedCid { codes: Decoded::default(), font: DictRef::new(named), vertical, ems: vertical })) } - /// The text, the composite font and the CIDFont's name of [`Self::unicode_type`]. - fn unicode_parts(&self, s: &[u8]) -> Option<(String, DictRef, Obj)> { + /// The CMap's name, the composite font, the CIDFont's name and the writing mode of + /// [`Self::named_cid_type`]. + fn named_cid_parts(&self) -> Option<(String, DictRef, Obj, bool)> { let font = self.g.font.clone()?; let f = font.borrow(); if font_type(&f) != Some(0.0) || f.get(&Key::name("FMapType")).and_then(Obj::as_num) != Some(9.0) { @@ -959,29 +1144,84 @@ impl Interp<'_> { if cmap.contains_key(&Key::name("CodeSpaceRange")) { return None; } - let (form, _) = super::cid::unicode_cmap(&cmap.get(&Key::name("CMapName")).and_then(Obj::text)?)?; + let cmap_name = cmap.get(&Key::name("CMapName")).and_then(Obj::text)?.to_string(); + 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); let Some(Obj::Dict(dep)) = f.get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0)) else { return None }; let name = { let d = dep.borrow(); d.get(&Key::name("CIDFontName")).or_else(|| d.get(&Key::name("FontName"))).cloned()? }; - Some((super::cid::unicode_text(s, form), font.clone(), name)) + Some((cmap_name, font.clone(), name, wmode == Some(1.0))) } - /// `text` set as point type at the current point in font `font` (see - /// [`Self::unicode_type`]), which moves past it. - fn show_unicode(&mut self, text: &str, font: &DictRef) -> Res { - let at = self.user_point()?; - let saved = self.g.font.replace(font.clone()); - let r = self.set_text(text, at); - self.g.font = saved; - let (t, _, adv) = r?; - if !self.g.paint.is_none() && !self.g.null && !text.trim().is_empty() { - let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); + /// 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::predefined_cmap::unicode_cmap(cmap) { + return Some(Rc::new(super::predefined_cmap::CMapCodes::Unicode(form))); } - self.g.cur = Some(self.xf() * (at + adv)); - Ok(()) + if let Some((_, codes)) = self.predefined_cmaps.iter().find(|(n, _)| **n == *cmap) { + return codes.clone(); + } + let codes = super::predefined_cmap::codes(cmap).map(Rc::new); + // Only the names of the other predefined CMaps reach here (`named_cid_parts`), so the + // list stays short. + self.predefined_cmaps.push((Rc::from(cmap), codes.clone())); + codes + } + + /// 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; + // A font the `kshow` procedure set stays current. + if self.g.font.as_ref().is_none_or(|f| f.same(&named.font)) { + self.g.font = saved; + } + r + } + + /// 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 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) } /// The characters of a composite font shown at the current point, spaced as `spacing` says. @@ -1282,6 +1522,13 @@ const MAX_TYPE1: usize = 256; /// The warning for CID-keyed type in a CIDFont the file doesn't carry. const MISSING_CID_FONT: &str = "CID-keyed type in a font the file doesn't carry was left out"; +/// Most bytes [`Interp::named_cid_type`] holds for each byte of a string: two lists of codes (16 +/// bytes a code) and their characters (up to 4 bytes a character, a few characters a code). +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"; + /// 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 5e058acaf6..fe7085b522 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -33,7 +33,10 @@ //! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx //! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each //! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`, -//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Only the +//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Standard +//! Vertical Roman Alignment is written as `1 Tv` and `0 Tv`, and Tate-chu-yoko in horizontal +//! type as a Kumi block recorded for later use, `1 TY` … `0 TY` (§15.3.4); neither operator is +//! written for type that has neither mark. Only the //! revisable form is written: readers lay the type out again (§15.1). Fonts are named by their //! PostScript names in `%%DocumentFonts` and `%%IncludeFont` (§3.1) and re-encoded to //! Windows-1252 (`TE`, then `%AI3_BeginEncoding: _name name`, `TZ`, §3.3), the 8-bit encoding @@ -110,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 = diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index 5528ad7529..3882e37fe1 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -41,8 +41,12 @@ pub(super) fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) s.push_str(" 3 "); } } - ps::push_nums(s, &[f64::from(stop.midpoint) * 100.0, f64::from(stop.offset) * 100.0]); - s.push_str("%_Bs\n"); + // The ramp point to five decimals of its percent: a gradient's stops repeated many times + // keep their places in each run. + ps::push_num(s, f64::from(stop.midpoint) * 100.0); + s.push(' '); + ps::push_num_places(s, f64::from(stop.offset) * 100.0, 5); + s.push_str(" %_Bs\n"); } s.push_str("BD\n%AI5_EndGradient\n"); } diff --git a/crates/eps/src/legacy_ai/prolog.rs b/crates/eps/src/legacy_ai/prolog.rs index b0d606bba9..5ee77f89cf 100644 --- a/crates/eps/src/legacy_ai/prolog.rs +++ b/crates/eps/src/legacy_ai/prolog.rs @@ -106,7 +106,7 @@ VCLegacy begin /TE {cleartomark} bd /TZ {cleartomark} bd /To {pop} bd /Tp {7 {pop} repeat} bd /TP {} bd /TO {} bd /Tr {pop} bd /Tf {pop pop pop pop} bd /Ts {pop} bd /Tz {pop pop} bd /Tt {pop} bd /TA {pop} bd -/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd +/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd /Tv {pop} bd /TY {pop} bd % The page trailer (3): annotatepage marks nothing. /annotatepage {} bd end diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index b9ffcd0305..cfb7af8186 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -110,6 +110,119 @@ fn conic_gradients_are_written_as_wedges_clipped_to_the_shape() { } } +/// The format's gradients pad past their ends: a linear gradient that repeats or reflects is +/// written with its stops repeated across the shape (a definition of its own, not named after the +/// swatch), and a conic one as wedges that repeat with the stops (which print as the canvas +/// draws them), with a warning. +#[test] +fn spread_gradients_are_written_with_their_stops_repeated() { + 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 linear = |spread| { + let mut g = GradientPaint::new(Gradient { spread, repeats: 3, ..Gradient::new(GradientKind::Linear, stops.clone()) }); + // Across the shape: three runs. + g.geom = Some(GradientGeom { + start: vectorcraft_geom::Point::new(20.0, 50.0), + end: vectorcraft_geom::Point::new(180.0, 50.0), + aspect: 1.0, + focal: None, + }); + g.swatch = Some("Bands".into()); + Paint::Gradient(Box::new(g)) + }; + for (spread, colors) in [ + (GradientSpread::Repeat, ["0 0 1", "1 0 0", "0 0 1", "1 0 0", "0 0 1", "1 0 0"]), + (GradientSpread::Reflect, ["0 0 1", "1 0 0", "1 0 0", "0 0 1", "0 0 1", "1 0 0"]), + ] { + 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)), linear(spread), 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), "{:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes); + assert!(text.contains("(Gradient) 0 6 Bd") && !text.contains("(Bands)"), "{text}"); + // A linear definition lists its stops from the last to the first, each RGB stop as + // `c m y k r g b 2 …`. + let def = text.split("(Gradient) 0 6 Bd\n[\n").nth(1).unwrap(); + for (line, rgb) in def.lines().take(6).zip(colors) { + assert!(line.contains(&format!(" {rgb} 2 ")), "{spread:?}: {line} lacks {rgb}\n{def}"); + } + } + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Reflect, repeats: 3, ..Gradient::new(GradientKind::Conic, stops) }); + g.geom = Some(GradientGeom { + start: vectorcraft_geom::Point::new(100.0, 50.0), + end: vectorcraft_geom::Point::new(140.0, 30.0), + aspect: 1.0, + 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 text = String::from_utf8_lossy(&write(&doc).bytes).into_owned(); + assert!(text.matches("\nf\n").count() >= 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 { @@ -1193,6 +1332,59 @@ fn variable_font_settings_are_warned_as_left_out() { assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); } +/// Standard Vertical Roman Alignment is written as `Tv` and Tate-chu-yoko in horizontal type as a +/// Kumi block recorded for later use, `TY` (§15.3.4), each where it changes: the marks read back +/// on the same characters, a block is closed before its text object ends, and type with neither +/// mark has neither operator. +#[test] +fn vertical_roman_and_tate_chu_yoko_are_written_and_read_back() { + use vectorcraft_doc::{TextObject, TextRun}; + let plain = style(12.0, Paint::solid(Color::BLACK)); + let roman = vectorcraft_doc::CharStyle { standard_vertical_roman: true, ..plain.clone() }; + let tcy = vectorcraft_doc::CharStyle { tate_chu_yoko: true, ..plain.clone() }; + let both = vectorcraft_doc::CharStyle { tate_chu_yoko: true, standard_vertical_roman: true, ..plain.clone() }; + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut marked = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "A", plain.clone()); + for (text, st) in [("12", &tcy), ("b", &roman), ("c", &both), ("34", &tcy)] { + marked.runs.push(TextRun::new(text, st.clone())); + } + let next = TextObject::point(vectorcraft_geom::Point::new(20.0, 80.0), "D", plain.clone()); + for t in [marked, next] { + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let out = write(&doc); + assert!(!out.warnings.iter().any(|w| w == TYPE || w == TYPE_ATTRIBUTES), "{:?}", out.warnings); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + assert_eq!((text.matches("1 TY\n").count(), text.matches("0 TY\n").count()), (2, 2), "{text}"); + assert_eq!((text.matches("1 Tv\n").count(), text.matches("0 Tv\n").count()), (1, 1), "{text}"); + // The last block ends before its text object does. + assert!(text.contains("(34) Tx\n0 TY\nTO\n"), "{text}"); + let read = texts(&crate::import(&out.bytes).unwrap().document); + let flags = |t: &TextObject| -> Vec<(String, bool, bool)> { + let mut out: Vec<(String, bool, bool)> = vec![]; + for r in &t.runs { + let key = (r.style.tate_chu_yoko, r.style.standard_vertical_roman); + match out.last_mut() { + Some(last) if (last.1, last.2) == key => last.0.push_str(&r.text), + _ => out.push((r.text.clone(), key.0, key.1)), + } + } + out + }; + let s = |x: &str| x.to_string(); + assert_eq!( + flags(&read[0].1), + [(s("A"), false, false), (s("12"), true, false), (s("b"), false, true), (s("c"), true, true), (s("34"), true, false)] + ); + assert_eq!(flags(&read[1].1), [(s("D"), false, false)]); + + // Type with neither mark has neither operator. + let text = String::from_utf8(write(&typed()).bytes).unwrap(); + assert!(!text.contains(" Tv\n") && !text.contains(" TY\n"), "{text}"); +} + /// A legacy `.ai` text path holds the path and where the type starts on it, and no Align to Path /// or end bracket: type on a path with either is written as text, with the warning that they are /// left out. An end bracket at the end of an open path, or once around a closed one, is kept. A diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 3373330892..19d3c59bb1 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, @@ -39,6 +39,10 @@ pub(super) struct Writer<'a> { in_hidden: bool, /// The fonts type is set in: PostScript name, and whether it's TrueType. pub(super) fonts: Vec<(String, bool)>, + /// The Standard Vertical Roman Alignment the last `Tv` set (it lasts into later text objects). + roman: bool, + /// Inside a Kumi block `1 TY` began (Tate-chu-yoko in horizontal type). + kumi: bool, } impl<'a> Writer<'a> { @@ -59,6 +63,8 @@ impl<'a> Writer<'a> { hidden_art: vec![], in_hidden: false, fonts: vec![], + roman: false, + kumi: false, } } @@ -481,7 +487,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; @@ -495,7 +502,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); } @@ -672,6 +679,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 { @@ -819,6 +842,10 @@ impl<'a> Writer<'a> { self.line(" Tx"); } } + if self.kumi { + self.line("0 TY"); + self.kumi = false; + } self.line("TO"); } @@ -838,7 +865,8 @@ impl<'a> Writer<'a> { } /// A run's character attributes and paint style: `Tr`, its fill and stroke, `Tf`, `Ts`, `Tz`, - /// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`. + /// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`; then `Tv` and `TY` + /// where Standard Vertical Roman Alignment or Tate-chu-yoko changes. fn char_style(&mut self, st: &CharStyle, p: &ParaStyle) { let fill = !st.fill.is_none(); let stroke = st.has_stroke(); @@ -876,6 +904,14 @@ impl<'a> Writer<'a> { let leading = st.leading.unwrap_or_else(|| p.justification.auto_leading_for(st.size)); ps::push_nums(&mut self.body, &[finite(leading, size * 1.2), finite(p.space_before, 0.0)]); self.line("Tl"); + if st.standard_vertical_roman != self.roman { + self.roman = st.standard_vertical_roman; + self.line(if self.roman { "1 Tv" } else { "0 Tv" }); + } + if st.tate_chu_yoko != self.kumi { + self.kumi = st.tate_chu_yoko; + self.line(if self.kumi { "1 TY" } else { "0 TY" }); + } } /// Type as its glyphs' outlines: a group of a compound path for each run, with the run's fill diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index 3f4b1049f5..8fef6d75dd 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -33,11 +33,19 @@ pub(crate) fn num(v: f64) -> 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); +} + +/// 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 +54,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 +299,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 +316,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..b503421eb7 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| { diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 863c6fdd34..8e7149cad2 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -18,6 +18,25 @@ use std::io::{self, Write as _}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +/// Whether the returned write error refuses this location. Recovery errors must still be shown: +/// their message names copies the user may need, even when their kind is PermissionDenied. +pub fn read_only_location(error: &io::Error) -> bool { + matches!(error.kind(), io::ErrorKind::ReadOnlyFilesystem | io::ErrorKind::PermissionDenied) + && !error.get_ref().is_some_and(|e| e.is::()) +} + +/// Marks the existing recovery message without changing its text or the returned error kind. +#[derive(Debug)] +struct RecoveryError(String); + +impl std::fmt::Display for RecoveryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for RecoveryError {} + /// Write `bytes` to `path` atomically (see the module docs). pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { write_atomic_with(path, |f| f.write_all(bytes)) @@ -102,7 +121,7 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> { let _ = std::fs::remove_file(old); let why = format!("{refused}, and the file can't be copied aside before it is overwritten ({e}); the new content is kept in {}", tmp.display()); - return Err(io::Error::new(refused.kind(), why)); + return Err(io::Error::new(refused.kind(), RecoveryError(why))); } let copied = to.set_len(0).and_then(|()| io::copy(&mut from, &mut to)).and_then(|_| sync_file(&to)); drop((from, to)); @@ -115,10 +134,11 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> { } Ok(()) } - (Err(e), Some(old)) => { - Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display()))) - } - (Err(e), None) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))), + (Err(e), Some(old)) => Err(io::Error::new( + e.kind(), + RecoveryError(format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display())), + )), + (Err(e), None) => Err(io::Error::new(e.kind(), RecoveryError(format!("{e} (the new content is kept in {})", tmp.display())))), } } @@ -318,6 +338,24 @@ fn keep_permissions(_: &Path, _: &Path) -> io::Result<()> { mod tests { use super::*; + #[test] + fn readonly_classification_uses_the_terminal_kind_and_excludes_recovery() { + for (kind, expected) in [ + (io::ErrorKind::PermissionDenied, true), + (io::ErrorKind::ReadOnlyFilesystem, true), + (io::ErrorKind::StorageFull, false), + (io::ErrorKind::QuotaExceeded, false), + (io::ErrorKind::NotFound, false), + (io::ErrorKind::Other, false), + ] { + assert_eq!(read_only_location(&io::Error::new(kind, "injected")), expected, "{kind:?}"); + let recovery = io::Error::new(kind, RecoveryError("the new content is kept in a.tmp".into())); + assert!(!read_only_location(&recovery), "recovery: {kind:?}"); + assert_eq!(recovery.kind(), kind); + assert_eq!(recovery.to_string(), "the new content is kept in a.tmp"); + } + } + fn dir(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("vc-atomic-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index cd64fcbb50..4e23ec0842 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -55,7 +55,7 @@ use serde::{Deserializer, Serialize}; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] -pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with}; +pub use atomic::{read_only_location, sync_file, write_atomic, write_atomic_with, write_new_with}; pub use craft_vectorcraft_format::base64::{decode as base64_decode, decoded_len as base64_decoded_len, encode as base64_encode}; pub use craft_vectorcraft_format::{ BLOB_MAGIC, COMPRESSED_SINCE, EXTENSION, ICC_MIME, INLINE_MAX, LEGACY_EXTENSION, MIN_VERSION, PREVIEW_MAX, VERSION, is_compressed, json_part, diff --git a/crates/geom/src/arc.rs b/crates/geom/src/arc.rs index c4fe640ae2..644dc44f6a 100644 --- a/crates/geom/src/arc.rs +++ b/crates/geom/src/arc.rs @@ -2,7 +2,7 @@ //! distance along it of the point nearest another (type on a path lays its glyphs out and the //! selection tools drag its brackets by distance along the path). -use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest, PathSeg, Point, Vec2}; +use kurbo::{Affine, ParamCurve, ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest, PathSeg, Point, Vec2}; use crate::PathData; @@ -76,6 +76,78 @@ impl ArcPath { pub fn fraction_at(&self, p: Point) -> Option { (self.len > 0.0).then(|| self.nearest(p)).flatten().map(|s| (s / self.len).clamp(0.0, 1.0)) } + + /// The distance along the path of the middle of its topmost points (the least y) where `xf` + /// draws it: the topmost point when there is one, the middle of a level stretch (a + /// rectangle's top edge), and when several points or stretches are topmost, the middle of the + /// shortest part of the path that holds them all. None for a path with no length, or a closed + /// path whose every point is topmost. + pub fn topmost(&self, xf: Affine) -> Option { + let drawn: Vec<(PathSeg, PathSeg, f64, f64)> = self.segs.iter().map(|&(s, c, l)| (s, xf * s, c, l)).collect(); + // The candidates for the least y in each segment: its ends and where it turns. + let turns = |d: &PathSeg| [0.0, 1.0].into_iter().chain(d.extrema()).collect::>(); + let mut bounds: Option = None; + let mut top = f64::INFINITY; + for (_, d, _, _) in &drawn { + let b = d.bounding_box(); + bounds = Some(bounds.map_or(b, |a| a.union(b))); + top = turns(d).into_iter().map(|t| d.eval(t).y).fold(top, f64::min); + } + let b = bounds?; + if !(top.is_finite() && b.width().is_finite() && b.height().is_finite()) { + return None; + } + // Points this close to the top count as topmost: a part of the drawn size, and no less than + // the rounding error of coordinates this far from the origin. + let tol = (1e-9 * (b.width() + b.height())).max(64.0 * f64::EPSILON * b.y0.abs().max(b.y1.abs())); + // Stretches this close along the path meet. + let near = 1e-9 * self.len; + // The topmost points as stretches of distance along the path, in order. + let mut runs: Vec<(f64, f64)> = vec![]; + for (s, d, c, l) in &drawn { + let db = d.bounding_box(); + if db.y1 - db.y0 <= tol && db.y0 <= top + tol { + runs.push((*c, c + l)); + continue; + } + for t in turns(d) { + if d.eval(t).y <= top + tol { + let at = c + s.subsegment(0.0..t).arclen(ACCURACY).min(*l); + runs.push((at, at)); + } + } + } + runs.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Stretches that meet or overlap are one. + let mut merged: Vec<(f64, f64)> = vec![]; + for (a, z) in runs { + match merged.last_mut() { + Some(last) if a <= last.1 + near => last.1 = last.1.max(z), + _ => merged.push((a, z)), + } + } + let (first, last) = (*merged.first()?, *merged.last()?); + if !self.closed { + return Some((first.0 + last.1) * 0.5); + } + let len = self.len; + // Round a closed path, the shortest part holding every stretch leaves out the longest + // gap between two of them (the gap from the last round to the first included). + let wrap = first.0 + len - last.1; + let (mut gap, mut from, mut to) = (wrap, first.0, last.1); + for w in merged.windows(2) { + if let [a, b] = w + && b.0 - a.1 > gap + { + (gap, from, to) = (b.0 - a.1, b.0, a.1); + } + } + if gap <= near { + return None; + } + let run = (to - from).rem_euclid(len); + Some((from + run * 0.5).rem_euclid(len)) + } } #[cfg(test)] @@ -99,4 +171,64 @@ mod tests { assert!(sq.at(45.0).0.distance(sq.at(5.0).0) < 1e-6); assert!(ArcPath::new(&PathData::default()).is_empty()); } + + fn polyline(points: &[Point], closed: bool) -> PathData { + PathData::single(crate::SubPath::polyline(points, closed)) + } + + /// Where `topmost` puts the middle, drawn by `xf`. + fn top_point(a: &ArcPath, xf: Affine) -> Point { + xf * a.at(a.topmost(xf).unwrap()).0 + } + + #[test] + fn topmost_points_as_drawn() { + // A circle starts at 9 o'clock and runs clockwise (y down): its top is a quarter of the way round, its + // bottom three quarters. + let circle = ArcPath::new(&shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0))); + assert!((circle.topmost(Affine::IDENTITY).unwrap() - circle.len() * 0.25).abs() < 1e-6); + assert!((circle.topmost(Affine::FLIP_Y).unwrap() - circle.len() * 0.75).abs() < 1e-6); + // As the transform draws it: turned a quarter turn the other way, 3 o'clock is at the top. + let turned = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(50.0, 50.0)); + assert!((circle.topmost(turned).unwrap() - circle.len() * 0.5).abs() < 1e-6); + // Turned part way, the top lies inside a segment. + let tilted = Affine::rotate_about(0.4, Point::new(50.0, 50.0)); + assert!(top_point(&circle, tilted).distance(Point::new(50.0, 0.0)) < 0.05, "{:?}", top_point(&circle, tilted)); + // An ellipse squashed and skewed by its transform. + let skewed = Affine::skew(0.5, 0.0) * Affine::scale_non_uniform(2.0, 0.5); + let p = top_point(&circle, skewed); + let lowest = (0..3600).map(|i| (skewed * circle.at(circle.len() * i as f64 / 3600.0).0).y).fold(f64::INFINITY, f64::min); + assert!((p.y - lowest).abs() < 1e-3, "{p:?} {lowest}"); + // A level top: the middle of the top edge, for a rectangle and a rounded rectangle. + for path in [shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), shapes::rounded_rectangle(Rect::new(10.0, 20.0, 110.0, 70.0), 8.0)] { + let a = ArcPath::new(&path); + assert!(top_point(&a, Affine::IDENTITY).distance(Point::new(60.0, 20.0)) < 1e-6, "{:?}", top_point(&a, Affine::IDENTITY)); + assert!(top_point(&a, Affine::FLIP_Y).distance(Point::new(60.0, -70.0)) < 1e-6); + } + // Far from the origin the top is still the top edge's middle. + let square = ArcPath::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); + let far = Affine::translate((1e12, 1e12)); + assert!(top_point(&square, far).distance(Point::new(1e12 + 50.0, 1e12)) < 1e-3, "{:?}", top_point(&square, far)); + // A level top across the path's start: the start in the middle of the top edge. + let across = ArcPath::new(&polyline( + &[Point::new(50.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 50.0), Point::new(0.0, 50.0), Point::new(0.0, 0.0)], + true, + )); + assert!(top_point(&across, Affine::IDENTITY).distance(Point::new(50.0, 0.0)) < 1e-6); + // Two peaks: the middle of the shortest part that holds both, the dip between them. + let peaks = ArcPath::new(&polyline( + &[Point::new(0.0, 10.0), Point::new(2.0, 0.0), Point::new(4.0, 5.0), Point::new(6.0, 0.0), Point::new(8.0, 10.0)], + true, + )); + assert!(top_point(&peaks, Affine::IDENTITY).distance(Point::new(4.0, 5.0)) < 1e-6, "{:?}", top_point(&peaks, Affine::IDENTITY)); + // An open path: the middle of its topmost points too. + let open = ArcPath::new(&polyline(&[Point::new(0.0, 10.0), Point::new(5.0, 0.0), Point::new(10.0, 10.0)], false)); + assert!(top_point(&open, Affine::IDENTITY).distance(Point::new(5.0, 0.0)) < 1e-6); + // No top: a closed path that is level all round, and a path with no length. + let flat = ArcPath::new(&shapes::ellipse(Rect::new(0.0, 10.0, 100.0, 10.0))); + assert!(!flat.is_empty() && flat.topmost(Affine::IDENTITY).is_none()); + assert!(ArcPath::new(&PathData::default()).topmost(Affine::IDENTITY).is_none()); + let point = ArcPath::new(&polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], true)); + assert!(point.topmost(Affine::IDENTITY).is_none()); + } } diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 54baeeb0c3..39bcde8d2e 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -358,6 +358,29 @@ fn save_file_waits_for_the_file_in_the_app() { } } +#[test] +fn readonly_save_file_keeps_the_stable_error_prefix() { + struct Refused(bool); + impl Backend for Refused { + fn call(&mut self, method: &str, params: Value) -> Result { + assert_eq!(method, "app.save"); + assert_eq!(params.get("wait").and_then(Value::as_bool), self.0.then_some(true)); + Err(vectorcraft_engine::EngineError::ReadOnlyLocation("Poster.svg: injected refusal".into()).to_string()) + } + fn has_ui(&self) -> bool { + self.0 + } + fn describe(&self) -> String { + "injected save refusal".into() + } + } + for desktop in [false, true] { + let r = call_tool(&mut Refused(desktop), "save_file", &json!({})); + assert!(r.is_error); + assert_eq!(r.content[0]["text"], "save_file: READ_ONLY_LOCATION: Poster.svg: injected refusal"); + } +} + fn pdf_pages(pdf: &[u8]) -> usize { let mut h = Headless::new(); h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}})) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index b07964b225..054cfec267 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -39,7 +39,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.save", "Save", "Cmd+S", - "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first; ordinary Save to the document's existing file refused by filesystem permissions or a read-only volume opens Save As in Documents with the same name, format and options; cancelling keeps the original path and modified state. Explicit destinations and Close/Quit keep their existing error dialog. Automation gets READ_ONLY_LOCATION: followed by the reason and no panel", ), ( "file.place", @@ -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]}"), ( @@ -801,7 +801,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", diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index 5326e2f307..a517d516a6 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -84,7 +84,7 @@ pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result emf::write(&ops, r, &doc.title), + Kind::Emf => emf::write(&ops, r, doc.info_title()), Kind::Wmf => wmf::write(&ops, r, &mut warnings), }; Ok(Output { bytes, warnings }) 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/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 5a19e62564..788cacea3a 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -53,7 +53,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let raised = for_long_pages(set, &pages)?; let set = raised.as_ref().unwrap_or(set); - let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone())); + // The file's Title is File Info's; the page information names the document, as its tab does. + let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.info_title().to_owned())); + let name = opts.title.as_deref().unwrap_or(&doc.title); // The time of writing (none on the web) dates the editing data and the page information. let now = vectorcraft_doc::metadata::output_time(); let created_at = opts.created.or(now); @@ -108,7 +110,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1)); let (m, r) = (boxes.media, boxes.bleed); - let info = if marks.page_info { crate::marks::page_info(doc, &title, i, now) } else { String::new() }; + let info = if marks.page_info { crate::marks::page_info(doc, name, i, now) } else { String::new() }; let art = crate::marks::art(doc, &marks, &boxes, bleed, &info); // The page is the media box, y down from its top-left corner (like ours), in units of // `unit` points. 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/file_info.rs b/crates/pdf/src/file_info.rs index 983a7dcea7..1cb0a45273 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -24,6 +24,8 @@ const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace /// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FileInfo { + /// XMP `dc:title` (its `x-default` item, else its first), else Info `Title`. + pub title: Option, /// XMP `dc:creator` (its names joined with ", "), else Info `Author`. pub author: Option, /// XMP `dc:description` (its `x-default` item, else its first), else Info `Subject`. @@ -53,6 +55,7 @@ pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo { let mut f = packet(pdf).and_then(|p| xmp(&p)).unwrap_or_default(); let info = pdf.metadata(); let text = |b: &Option>| b.as_deref().map(text_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + f.title = f.title.or_else(|| text(&info.title)); f.author = f.author.or_else(|| text(&info.author)); f.description = f.description.or_else(|| text(&info.subject)); f.keywords = f.keywords.or_else(|| text(&info.keywords).map(|k| split(&k)).filter(|k| !k.is_empty())); @@ -90,6 +93,7 @@ fn xmp(packet: &[u8]) -> Option { let creators = get(DC, "creator"); let subjects = get(DC, "subject"); Some(FileInfo { + title: first(DC, "title"), author: (!creators.is_empty()).then(|| creators.join(", ")), description: first(DC, "description"), keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) }, 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..3f74dd9d28 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; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d8238d8038..d63aaedb35 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -44,7 +44,9 @@ mod printadvanced; mod reader_compat; mod reader_streaming; mod realtext; +mod reshaped; mod settings; +mod spread; pub(super) mod stroke; mod strokeout; mod text_placement; diff --git a/crates/pdf/src/tests/file_info.rs b/crates/pdf/src/tests/file_info.rs index 7ce2f42f47..4c837ba67f 100644 --- a/crates/pdf/src/tests/file_info.rs +++ b/crates/pdf/src/tests/file_info.rs @@ -45,7 +45,7 @@ fn description(attrs: &str, body: &str) -> String { } const INFO: &str = "/Author (Grace Hopper) /Subject /Keywords (navy; compilers, , cobol) \ - /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Ignored title)"; + /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Compiler manual)"; /// Unix seconds of 2026-10-05 12:03:00 UTC (14:03 at +02:00). const CREATED: i64 = 1_791_201_780; @@ -58,6 +58,7 @@ fn the_information_dictionary_gives_file_info() { assert_eq!( f, FileInfo { + title: Some("Compiler manual".into()), author: Some("Grace Hopper".into()), description: Some("Cata log".into()), keywords: Some(vec!["navy".into(), "compilers".into(), "cobol".into()]), @@ -79,7 +80,8 @@ fn the_information_dictionary_gives_file_info() { fn the_xmp_packet_gives_file_info() { let body = description( "xmpRights:Marked=\"False\" xmp:ModifyDate=\"2026-10-06T09:00:00.25Z\"", - "Ada LovelaceCharles Babbage\ + "PlakatPoster\ + Ada LovelaceCharles Babbage\ Ein PlakatA poster\nfor the fair\ fair print \ (c) 2026 Ada\ @@ -90,6 +92,7 @@ fn the_xmp_packet_gives_file_info() { assert_eq!( f, FileInfo { + title: Some("Poster".into()), author: Some("Ada Lovelace, Charles Babbage".into()), description: Some("A poster\nfor the fair".into()), keywords: Some(vec!["fair".into(), "print".into()]), @@ -138,6 +141,13 @@ fn the_xmp_packet_wins_where_both_give_a_field() { assert_eq!(f.created, Some(1_791_331_200 + 5 * 3600 + 30 * 60)); assert_eq!(f.description.as_deref(), Some("Cata log")); assert_eq!(f.modified, Some(MODIFIED)); + // The title is the packet's where it gives one (a blank `x-default` item leaves the next one), + // else the dictionary's. + assert_eq!(f.title.as_deref(), Some("Compiler manual")); + let body = + description("", " Manuel"); + let f = file_info(&pdf(Some(INFO), Some(&packet(&body))), None).unwrap(); + assert_eq!(f.title.as_deref(), Some("Manuel")); } #[test] @@ -197,17 +207,29 @@ fn an_xmp_packet_nested_too_deep_leaves_the_information_dictionary() { #[test] fn a_written_pdf_gives_its_file_info_back() { let mut d = Document::new(100.0, 100.0); + d.title = "Fair poster".into(); d.metadata.author = "Ada Lovelace".into(); d.metadata.description = "A poster\nfor the fair".into(); d.metadata.keywords = vec!["fair".into(), "print".into()]; let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap(); let f = file_info(&bytes, None).unwrap(); + assert_eq!(f.title.as_deref(), Some("Fair poster")); assert_eq!(f.author.as_deref(), Some("Ada Lovelace")); assert_eq!(f.description.as_deref(), Some("A poster\nfor the fair")); assert_eq!(f.keywords, Some(vec!["fair".to_string(), "print".into()])); assert_eq!((f.created, f.modified), (Some(CREATED), Some(CREATED))); } +#[test] +fn a_written_pdf_carries_the_file_info_title_not_the_documents_name() { + let mut d = Document::new(100.0, 100.0); + d.title = "poster.svg".into(); + d.metadata.title = Some("Fair poster".into()); + let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap(); + assert_eq!(file_info(&bytes, None).unwrap().title.as_deref(), Some("Fair poster")); + assert!(!String::from_utf8_lossy(&bytes).contains("poster.svg")); +} + #[test] fn a_written_pdf_gives_its_copyright_back() { use vectorcraft_doc::CopyrightStatus; 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/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/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 0e149579f5..98cf893dee 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 { 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/bounds.rs b/crates/render/src/fx/bounds.rs index 9d5e9c0d1a..4b5055e67f 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -505,17 +505,31 @@ fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option { /// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual /// bounds: for each object in it, how far the effects in a symbol instance's art paint past that -/// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the -/// farthest side. 0 for any other kind of object. +/// art ([`art_effects_reach`]), how far brush art can paint ([`crate::brush_fx::pad`]) and how far +/// a path's own effects paint past it ([`path_effects_reach`]), on the farthest side. 0 for any +/// other kind of object. fn live_content_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { if !vectorcraft_doc::live::is_live(n) { return 0.0; } let mut reach = 0.0f64; - n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c) + crate::brush_fx::pad(symbols, c))); + n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c).max(path_effects_reach(symbols, c)) + crate::brush_fx::pad(symbols, c))); reach } +/// How far the effects of path or compound path `n` (a drop shadow, a glow, a geometry effect) +/// paint past its visual bounds, on its farthest side ([`visual_bounds`]): 0 when it has no +/// visible effects, and for any other kind of object. +fn path_effects_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { + if !has_fx(n) { + return 0.0; + } + match (visual_bounds(symbols, n), n.visual_bounds_with(symbols)) { + (Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max), + _ => 0.0, + } +} + /// How far the effects in the art of symbol instance `n` (and in the art of the symbols the /// instances in it show) paint past that art's visual bounds, on its farthest side, measured on the /// art as the instance places it before its effects run: 0 when the art has no visible effects, and diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 3f5ebbb2fe..2b6610beb1 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -781,7 +781,7 @@ mod tests { d.symbols.push(Symbol { dynamic: false, name: format!("S{k}"), art: Arc::new(art) }); } let first = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S0".into(), xf: Affine::IDENTITY }); - let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![Arc::new(first)], kind: Box::new(kind), @@ -815,7 +815,7 @@ mod tests { d.symbols.push(Symbol { dynamic: false, name: name.into(), art: art.clone() }); } let content = ["A", "B", "C"].map(|s| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: s.into(), xf: Affine::IDENTITY }))); - let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: content.to_vec(), kind: Box::new(kind), 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.rs b/crates/render/src/tests.rs index 83f6131071..9cf4ac9ca0 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1260,6 +1260,45 @@ fn undecodable_image_in_nested_opacity_mask_leaves_out_outer_object() { assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "the incomplete inner mask invalidates the outer mask"); } +/// The drop shadows of a blend's steps draw under an opacity mask with Clip off, where the mask +/// art leaves them uncovered, as they draw without the mask; with Clip on they are hidden. +#[test] +fn blend_step_shadows_draw_under_an_unclipped_opacity_mask() { + use vectorcraft_doc::live::{BlendSpacing, BlendSpec}; + use vectorcraft_doc::{NodeKind, OpacityMask}; + + let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 15, "y": 15, "blur": 0}), visible: true }; + let blend = |d: &mut Document, mask: Option| { + let end = |d: &mut Document, x: f64| { + let mut n = rect_node(d, Rect::new(x, 10.0, x + 10.0, 20.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + n.appearance.effects.push(shadow.clone()); + Arc::new(n) + }; + let ends = vec![end(d, 10.0), end(d, 70.0)]; + let spec = BlendSpec { spacing: BlendSpacing::Steps(2), ..Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Blend { children: ends, spec: Box::new(spec) }); + if let Some(clip) = mask { + let white = rect_node(d, Rect::new(0.0, 0.0, 5.0, 5.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(white, clip))); + } + n + }; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let draw = |mask: Option| { + let mut d = Document::new(120.0, 60.0); + let n = blend(&mut d, mask); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + Renderer::new().render(&d, 120, 60, Affine::IDENTITY, &opts) + }; + let (plain, unclipped, clipped) = (draw(None), draw(Some(false)), draw(Some(true))); + for (x, y) in [(30, 30), (90, 32)] { + assert!(plain.pixel(x, y)[0] < 128, "the shadow draws at ({x}, {y}) without a mask: {:?}", plain.pixel(x, y)); + assert_eq!(unclipped.pixel(x, y), plain.pixel(x, y), "the shadow draws at ({x}, {y}) under the unclipped mask"); + assert_eq!(clipped.pixel(x, y), [255, 255, 255, 255], "the clipped mask hides the shadow at ({x}, {y})"); + } + assert_eq!(unclipped.pixel(15, 15), plain.pixel(15, 15), "the first end draws under the unclipped mask"); +} + /// #3580: each opacity mask is rendered over its object's pixels, not over the whole frame. 300 /// masked 4 × 4 pt squares on a 1000 × 1000 page draw offscreen in proportion to their bounds, and /// still draw where their masks show them. diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 8f6d68f575..0e7864b0b9 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -338,7 +338,7 @@ fn enveloped_type_redraws_when_its_font_becomes_available() { let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 120.0)) }); let l = d.layers[0].id; for (i, content) in [typed, shown].into_iter().enumerate() { - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![Arc::new(content)], kind: Box::new(kind), @@ -376,7 +376,7 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() { let mut d = Document::new(300.0, 150.0); let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() }; let typed = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 80.0), "Hamburg", style)))); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![Arc::new(typed)], kind: Box::new(kind), @@ -420,7 +420,7 @@ fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() { const FAMILY: &str = "Mapped Serif 4"; let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() }; let t = TextObject::point(Point::new(10.0, 60.0), "Hamburg", style); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = Node::new( vectorcraft_doc::NodeId(1), NodeKind::Envelope { diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index d5e6919f0d..f95735baec 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -1252,7 +1252,7 @@ fn art_with_live_objects_draws_pattern_layout_edits() { d.patterns.push(def); let band = vectorcraft_geom::shapes::rectangle(Rect::new(-80.0, -40.0, 80.0, 40.0)); let filled = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0)); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let options = EnvelopeOptions { distort_appearance: true, distort_pattern_fills: true, ..Default::default() }; let envelope = Node::new( d.alloc_id(), @@ -1398,7 +1398,7 @@ fn live_objects_in_symbol_art_draw_symbol_and_pattern_edits() { /// An envelope of `d` bending `content` with an arc warp (30%); with `patterns`, Distort /// Appearance and Distort Pattern Fills are on, and the tiles of its pattern fills bend with it. fn arc_envelope(d: &mut Document, content: Vec, patterns: bool) -> Node { - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance: patterns, distort_pattern_fills: patterns, ..Default::default() }; let content = content.into_iter().map(Arc::new).collect(); Node::new( diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index fe1e5ee1d1..7e4007c6ac 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -439,7 +439,7 @@ fn transforms_of(t: &Node) -> [(Value, Affine); 3] { /// A 100×100 document holding `content` in an envelope that leaves it as it is (an arc with no /// bend), with Distort Appearance on or off. fn envelope_doc(content: Node, distort_appearance: bool) -> Document { - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance, ..Default::default() }; let envelope = NodeKind::Envelope { content: vec![Arc::new(content)], diff --git a/crates/render/src/tests/placed_document.rs b/crates/render/src/tests/placed_document.rs index c3c00a4cf0..3a65b1abe0 100644 --- a/crates/render/src/tests/placed_document.rs +++ b/crates/render/src/tests/placed_document.rs @@ -135,7 +135,7 @@ fn an_enveloped_placed_document_shows_its_file_once_read() { let placed = d.remove(NodeId(50)).unwrap(); let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("a placed document") }; let p = (**p).clone(); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![placed], kind: Box::new(kind), 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/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/svg/src/css.rs b/crates/svg/src/css.rs index ada9a4375c..79d4d52c11 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 { @@ -559,9 +566,25 @@ 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); + // 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, ""), + }; let stops = |pos: &dyn Fn(f32) -> f64| { 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, 2))).collect(); stops.join(", ") }; match g.gradient.kind { @@ -578,7 +601,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))) } // CSS has no diamond gradient: the ellipse through the square's corners stands in. GradientKind::Radial | GradientKind::Diamond => { @@ -594,7 +617,7 @@ impl Rules<'_> { format!("ellipse {} {}", self.len(r), self.len(r * geom.aspect)) }; 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(&|t| t as f64))) } GradientKind::Conic => { // CSS sweeps clockwise from the `from` angle, 0deg straight up @@ -607,7 +630,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 eed8ecd335..8eb18626f3 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -188,7 +188,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: // An artboard exported on its own is named after it (#3544), else after the document. // A name that is blank once the characters XML can't hold are dropped falls back too. let board = opts.artboard.filter(|_| opts.artboard_title).and_then(|i| doc.artboards.get(i)).map(|a| xml_text(&a.name)); - let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(&doc.title)); + let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(doc.info_title())); if !title.is_empty() { out.push_str(&w.indent(1)); out.push_str(&format!("{title}{nl}")); diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 3ab4003d7d..c5a98aebf1 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -121,6 +121,21 @@ impl Reuse { } } +/// Are `children` the pieces of a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`]): +/// more than one, each a clip group of a triangle and one and the same image? +fn image_pieces(children: &[std::sync::Arc]) -> bool { + let mut first: Option<(&str, u32, u32)> = None; + children.len() > 1 + && children.iter().all(|c| { + let NodeKind::Group { children, clip: true } = &c.kind else { return false }; + let [clip, im] = children.as_slice() else { return false }; + let (NodeKind::Path { path, .. }, NodeKind::Image(im)) = (&clip.kind, &im.kind) else { return false }; + let triangle = path.subpaths.len() == 1 && path.anchor_count() == 3; + let image = (im.key.as_str(), im.width, im.height); + triangle && *first.get_or_insert(image) == image + }) +} + /// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas /// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement /// and the art of unlinked opacity masks. @@ -678,6 +693,10 @@ impl Writer<'_> { match &n.kind { NodeKind::Layer { template: true, .. } => {} NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + // The pieces a Warp effect cuts an image into share its pixels, as an envelope's do. + if image_pieces(children) { + self.share_images(n); + } let id = self.id_attr(n); let a = self.attrs(&self.group_props(n)); self.line(&format!("")); diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index 4f91d6c970..7c30f23f5a 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -88,7 +88,7 @@ fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> { let info = &doc.metadata; let mut terms = vec![("format", "image/svg+xml".to_string())]; let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default(); - let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; + let fields = [("title", doc.info_title()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())]; let keywords = info.keywords.iter().map(|k| ("subject", k.as_str())); terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string()))); diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index e982ab315c..515e556a4b 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -273,7 +273,15 @@ impl Writer<'_> { // Object fills and strokes are four linear gradients or images (see `raster_paint`). self.warn("diamond gradients on characters are written as radial gradients"); } + // A gradient that repeats or reflects: one run of its stops over its period, and + // `spreadMethod` to carry it on past both ends. + let spread = g.gradient.is_spread(); let mut geom = g.resolve(bounds); + if spread { + // A conic is written as a linear gradient along its vector: its runs lie along it. + let written = if g.gradient.kind == GradientKind::Conic { GradientKind::Linear } else { g.gradient.kind }; + geom = geom.period(written, g.gradient.repeat_count()); + } geom.transform(self.xf, g.gradient.kind); if g.gradient.kind != GradientKind::Radial { // Only a radial gradient has a focal point (a plug-in's or a file's other kinds can @@ -304,6 +312,11 @@ impl Writer<'_> { format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)), ), }; + let attrs = match g.gradient.spread { + _ if !spread => attrs, + vectorcraft_color::GradientSpread::Reflect => format!("{attrs} spreadMethod=\"reflect\""), + _ => format!("{attrs} spreadMethod=\"repeat\""), + }; let stops: Vec = g .gradient .expanded() @@ -558,10 +571,10 @@ impl Writer<'_> { } } None => { - let sample = g.gradient.sampler(); - for (pts, t) in g.resolve(bounds).conic_wedges(cover, CONIC_WEDGES, true) { + let (sample, spread) = (g.gradient.sampler(), g.gradient.spreader()); + for (pts, t) in g.resolve(bounds).conic_wedges(cover, g.gradient.conic_wedge_count(CONIC_WEDGES), true) { let d = self.path_d(&PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)), self.xf); - self.line(&format!("", sample(t as f32).0.to_hex())); + self.line(&format!("", sample(spread(t) as f32).0.to_hex())); } } }, diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 1e852ee531..4ac3530b19 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -38,7 +38,7 @@ pub(super) use importer::PT_PER_IN; use importer::SymbolMade; pub(super) use layers::DEFAULT_FONT_SIZE; pub(crate) use layers::SUBLAYER; -use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name}; +use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name, root_title}; pub(super) use prepass::Edits; use prepass::{Extras, labels, prepass}; pub use prepass::{MAX_FOREIGN, MAX_FOREIGN_VALUE}; @@ -49,7 +49,6 @@ mod depth; mod files; mod fx; mod linear_rgb; -mod spread; mod symbol_units; mod text; mod vector_effect; @@ -158,6 +157,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul let k = units.k; let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k); doc.units = units.unit; + // The file's own title, when it gives one, is the document's File Info title; the document's + // name stays what `Document::new` gave it, for the caller to name after the file. + doc.metadata.title = root_title(xml.root_element()); let mut im = Importer { doc, warnings, diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index ca31f28b85..33878ac998 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -4,10 +4,11 @@ use super::prepass::link; use super::{ Affine, Appearance, AppearanceItem, Arc, BlendMode, Color, Dash, FillLayer, FillRule, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, ImageBlob, ImageObject, Importer, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, PathData, PatternDef, Point, Rect, Reuse, - StrokeLayer, Vec2, fnv1a, fx, shapes, spread, text, vector_effect, + StrokeLayer, Vec2, fnv1a, fx, shapes, text, vector_effect, }; use crate::SvgError; use kurbo::BezPath; +use vectorcraft_color::GradientSpread; /// How many clip paths deep (``) an object's clip is read. const MAX_CLIP_NEST: usize = 16; @@ -246,10 +247,7 @@ impl Importer { // space; runs paint in text space. let to_text = Affine::scale(grow) * obj.xf.inverse(); let slot_paint = |im: &mut Self, k: usize, m: Affine| match kids.get(main + 1 + k) { - Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| { - let area = kurbo::Shape::bounding_box(&bezpath(p.data(), m)); - im.paint(f.paint(), m, Some(area)) - }), + Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| im.paint(f.paint(), m)), _ => Paint::None, }; let resolved: Vec = (0..paints).map(|k| slot_paint(self, k, to_text)).collect(); @@ -608,9 +606,8 @@ impl Importer { Some(NodeKind::Compound { children, rule: r }) } - /// A usvg paint mapped by `m`, for art covering `area` (after `m`: where a reflected or - /// repeated gradient has to reach). - fn paint(&mut self, p: &usvg::Paint, m: Affine, area: Option) -> Paint { + /// A usvg paint mapped by `m`. + fn paint(&mut self, p: &usvg::Paint, m: Affine) -> Paint { let (kind, base, mut geom) = match p { usvg::Paint::Color(c) => return Paint::solid(Color::rgb8(c.red, c.green, c.blue)), usvg::Paint::Pattern(pt) => return self.pattern(pt, m), @@ -637,28 +634,14 @@ impl Importer { } }; geom.transform(m * aff(base.transform()), kind); - let mut stops = self.stops(base); - let reflect = match base.spread_method() { - usvg::SpreadMethod::Pad => None, - usvg::SpreadMethod::Reflect => Some(true), - usvg::SpreadMethod::Repeat => Some(false), + let mut gradient = Gradient::new(kind, self.stops(base)); + // `spreadMethod` is the gradient's spread; its stops run once along the vector. + gradient.spread = match base.spread_method() { + usvg::SpreadMethod::Pad => GradientSpread::Pad, + usvg::SpreadMethod::Reflect => GradientSpread::Reflect, + usvg::SpreadMethod::Repeat => GradientSpread::Repeat, }; - if let Some(reflect) = reflect { - match area.map(|a| spread::expand(kind, &mut geom, &mut stops, reflect, a)) { - Some(spread::Expanded::Whole) => {} - Some(spread::Expanded::Capped) => { - self.warn(format!("gradient '{}': spreadMethod repeats more than {} times; it pads after that", base.id(), spread::MAX_PERIODS)) - } - None => self.warn(format!("gradient '{}': spreadMethod approximated as pad", base.id())), - } - } - Paint::Gradient(Box::new(GradientPaint { - gradient: Gradient::new(kind, stops), - geom: Some(geom), - angle: geom.angle_deg(), - swatch: None, - freeform: None, - })) + Paint::Gradient(Box::new(GradientPaint { gradient, geom: Some(geom), angle: geom.angle_deg(), swatch: None, freeform: None })) } /// `` → a pattern swatch painted with the pattern's tile placement. The swatch art is @@ -692,12 +675,12 @@ impl Importer { Paint::Pattern { pattern: name, xf } } - /// The fill and stroke of `p` mapped by `m`; `area`: the bounds of the path after `m`. - fn appearance(&mut self, p: &usvg::Path, m: Affine, area: Option) -> (Appearance, FillRule) { + /// The fill and stroke of `p` mapped by `m`. + fn appearance(&mut self, p: &usvg::Path, m: Affine) -> (Appearance, FillRule) { let mut fill = None; let mut r = FillRule::NonZero; if let Some(f) = p.fill() { - let paint = self.paint(f.paint(), m, area); + let paint = self.paint(f.paint(), m); let mut fl = FillLayer::new(paint); fl.opacity = f.opacity().get(); r = rule(f.rule()); @@ -708,9 +691,7 @@ impl Importer { // A non-scaling stroke is as wide on screen whatever `m` does: the path takes `m`, so a // stroke of that width in the document is exact. let scale = if self.non_scaling.contains(p.id()) { self.screen } else { m.determinant().abs().sqrt() }; - // The stroke reaches past the path (miters further: a generous margin). - let reach = s.width().get() as f64 * scale * (s.miterlimit().get() as f64).max(1.0); - let paint = self.paint(s.paint(), m, area.map(|a| a.inflate(reach, reach))); + let paint = self.paint(s.paint(), m); let mut sl = StrokeLayer::new(paint, s.width().get() as f64 * scale); sl.opacity = s.opacity().get(); sl.cap = match s.linecap() { @@ -749,7 +730,7 @@ impl Importer { if path.is_empty() { return None; } - let (appearance, r) = self.appearance(p, acc, path.bounds()); + let (appearance, r) = self.appearance(p, acc); // Shared, so a compound path's subpaths don't each copy a gradient's stops. let appearance: vectorcraft_doc::Shared = appearance.into(); let mut n = if path.subpaths.len() > 1 { diff --git a/crates/svg/src/import/layers.rs b/crates/svg/src/import/layers.rs index 15a77e37f0..a6d6a46b30 100644 --- a/crates/svg/src/import/layers.rs +++ b/crates/svg/src/import/layers.rs @@ -16,6 +16,16 @@ pub(super) fn root_layer_name(root: XNode) -> Option { root.attribute("data-name").or(id).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) } +/// The title the file gives root element `root`: the text of its first `` child (the one +/// SVG 1.1 §5.4 and SVG 2 §5.4 give a document or element; a `<title>` inside a shape or group is +/// that element's tooltip, not the document's), its runs of white space made single spaces. `None` +/// without one, or when it is blank. +pub(super) fn root_title(root: XNode) -> Option<String> { + let title = root.children().find(|c| c.is_element() && c.tag_name().name() == "title" && c.tag_name().namespace() == Some(super::SVG_NS))?; + let text: Vec<&str> = title.descendants().filter_map(|n| n.text().filter(|_| n.is_text())).flat_map(str::split_whitespace).collect(); + (!text.is_empty()).then(|| text.join(" ")) +} + /// Is element `g` a `<g>` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or /// the label beside it) is its name. pub(super) fn marked_layer(g: XNode) -> bool { diff --git a/crates/svg/src/import/spread.rs b/crates/svg/src/import/spread.rs deleted file mode 100644 index 1c6c1f3ec4..0000000000 --- a/crates/svg/src/import/spread.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! `spreadMethod="reflect"` and `"repeat"`: gradients only pad past their ends, so the stops are -//! repeated (every other period mirrored, for reflect) as many times as the painted area needs, -//! and the gradient stretched over those periods. - -use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop}; -use vectorcraft_geom::{Point, Rect}; - -/// Most periods a gradient is expanded to. -pub(super) const MAX_PERIODS: usize = 64; - -/// What [`expand`] did. -#[derive(Debug, PartialEq, Eq)] -pub(super) enum Expanded { - /// Every period the area shows. - Whole, - /// [`MAX_PERIODS`] of them: the gradient pads past those. - Capped, -} - -/// Expand `stops` and `geom` (document space) so the gradient paints `area` as the spread method -/// does: `reflect` mirrors every other period, else they repeat. -pub(super) fn expand(kind: GradientKind, geom: &mut GradientGeom, stops: &mut Vec<GradientStop>, reflect: bool, area: Rect) -> Expanded { - 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| geom.param_at(kind, p)); - if ts.iter().any(|t| !t.is_finite()) || stops.is_empty() { - return Expanded::Whole; - } - let radial = kind == GradientKind::Radial; - // The parameter's range over the area: a corner holds its extremes (radial levels are nested - // convex rings from the focal point, where the parameter is 0). - let lo = if radial { 0.0 } 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); - if lo >= 0.0 && hi <= 1.0 { - return Expanded::Whole; - } - let capped = hi - lo > MAX_PERIODS as f64; - // Capped, the periods kept start no further back than half of them. - let lo = if capped { lo.max(-(MAX_PERIODS as f64) / 2.0) } else { lo }; - let n = (hi - lo).min(MAX_PERIODS as f64); - let period = whole_period(stops); - let mut out = Vec::with_capacity(period.len() * n as usize); - for j in 0..n as usize { - let mirrored = reflect && (lo as i64 + j as i64).rem_euclid(2) == 1; - let one = if mirrored { mirror(&period) } else { period.clone() }; - out.extend(one.into_iter().map(|s| GradientStop { offset: ((j as f64 + s.offset as f64) / n) as f32, ..s })); - } - *stops = out; - let u = geom.end - geom.start; - if radial { - // Level t of an off-centre radial is the extent ellipse scaled by t about the line from the - // focal point to the centre; level n·t' of the new one: the centre moves away from the - // focal point n times as far, and the radius grows n times. - let Some(frame) = geom.unit_frame() else { return Expanded::Whole }; - let f = geom.focal.map_or(Point::ORIGIN, |f| frame.inverse() * f); - geom.start = frame * (f.to_vec2() * (1.0 - n)).to_point(); - geom.end = geom.start + u * n; - } else { - let start = geom.start; - geom.start = start + u * lo; - geom.end = start + u * (lo + n); - } - if capped { Expanded::Capped } else { Expanded::Whole } -} - -/// The stops of one period, from offset 0 to 1: the end colours held out to the ends. -fn whole_period(stops: &[GradientStop]) -> Vec<GradientStop> { - 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 -} - -/// A period mirrored: the stops in reverse, each midpoint measured from the other side. -fn mirror(period: &[GradientStop]) -> Vec<GradientStop> { - let mut g = Gradient::new(GradientKind::Linear, period.to_vec()); - g.reverse(); - g.stops -} - -#[cfg(test)] -mod tests { - use super::*; - use vectorcraft_color::Color; - - fn stops() -> Vec<GradientStop> { - let mut a = GradientStop::new(0.0, Color::BLACK); - a.midpoint = 0.3; - vec![a, GradientStop::new(1.0, Color::WHITE)] - } - - #[test] - fn a_linear_gradient_repeats_over_the_area() { - let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; - let mut s = stops(); - let r = expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(-5.0, 0.0, 25.0, 10.0)); - assert_eq!(r, Expanded::Whole); - // From -10 to 30: four periods. - assert_eq!((geom.start, geom.end), (Point::new(-10.0, 0.0), Point::new(30.0, 0.0))); - let offsets: Vec<f32> = s.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!(s.iter().step_by(2).all(|s| s.color == Color::BLACK && (s.midpoint - 0.3).abs() < 1e-6)); - } - - #[test] - fn reflect_mirrors_every_other_period_and_its_midpoints() { - let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; - let mut s = stops(); - expand(GradientKind::Linear, &mut geom, &mut s, true, Rect::new(0.0, 0.0, 20.0, 10.0)); - let colours: Vec<Color> = s.iter().map(|s| s.color).collect(); - assert_eq!(colours, [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!((s[2].midpoint - 0.7).abs() < 1e-6, "{s:?}"); - } - - #[test] - fn stops_inside_the_period_hold_their_colours_to_its_ends() { - let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; - let mut s = vec![GradientStop::new(0.25, Color::BLACK), GradientStop::new(0.75, Color::WHITE)]; - expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 20.0, 10.0)); - let offsets: Vec<f32> = s.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]); - } - - #[test] - fn a_radial_gradient_grows_about_its_focal_point() { - let c = Point::new(50.0, 50.0); - let mut geom = GradientGeom { start: c, end: Point::new(60.0, 50.0), aspect: 1.0, focal: Some(Point::new(55.0, 50.0)) }; - let before = geom.param_at(GradientKind::Radial, Point::new(72.0, 61.0)); - let mut s = stops(); - expand(GradientKind::Radial, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 100.0, 100.0)); - let n = s.len() / 2; - assert!(n >= 5, "{n}"); - assert_eq!(geom.focal, Some(Point::new(55.0, 50.0))); - // Every point keeps its level: the new parameter is the old one over n. - let after = geom.param_at(GradientKind::Radial, Point::new(72.0, 61.0)); - assert!((after * n as f64 - before).abs() < 1e-9, "{before} {after}"); - } - - #[test] - fn far_repeats_are_capped() { - let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None }; - let mut s = stops(); - assert_eq!(expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 100.0, 1.0)), Expanded::Capped); - assert_eq!(s.len(), 2 * MAX_PERIODS); - } -} diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 52b6663906..51bcffa41e 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -247,8 +247,9 @@ pub(super) fn prepare<'s>( && n.tag_name().namespace() == Some(SVG_NS) && !n.ancestors().any(|a| a.has_tag_name("style")) }; - // The slot of each text, by element, for the `<use>` elements in clip paths below. + // The slot of each text, by element, for the `<use>` elements below. let mut slot_of = HashMap::new(); + let mut inst = Instances::default(); for (t, style_t) in xml.descendants().filter(is_text).zip(style_xml.descendants().filter(is_text)) { let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); // CSS Masking Module Level 1 §6.1 (https://www.w3.org/TR/css-masking-1/#ClipPathElement): @@ -275,20 +276,22 @@ pub(super) fn prepare<'s>( // A `<use>` elsewhere can still show the text by its id: the clip path leaves out this // group (a group isn't clip path content), but the `<use>` finds it. if t.attribute("id").is_some() { - placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints, true); } } else { - placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints, true); } copied = r.end; edits.push((r, out)); slot_of.insert(t.id(), index); slots.texts.push(text); + inst.paints.push(paints); } if slots.texts.is_empty() { return (Cow::Borrowed(svg), slots); } - clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut edits, warnings); + clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut inst, &mut edits, warnings); + text_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut inst, &mut edits, warnings); edits.sort_by_key(|(r, _)| r.start); let mut out = String::with_capacity(svg.len()); let mut copied = 0; @@ -332,6 +335,7 @@ fn clip_uses( cx: &Ctx, slot_of: &HashMap<roxmltree::NodeId, usize>, slots: &mut TextSlots, + inst: &mut Instances, edits: &mut Vec<(Range<usize>, String)>, warnings: &mut Vec<String>, ) { @@ -343,18 +347,16 @@ fn clip_uses( for (k, (u, style_u)) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)).enumerate() { let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue }; let Some(t) = ids.get(id) else { continue }; - let Some(&i) = slot_of.get(&t.id()) else { continue }; - let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue }; + let Some(&shared) = slot_of.get(&t.id()) else { continue }; let Some(q) = qualifier(svg, u) else { continue }; // An undisplayed `<use>` adds nothing to the clip (usvg leaves it out as it is). if cx.css.own(style_u, "display").is_some_and(|d| d.trim() == "none") { continue; } let style_t = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()); - let em = cx.font_size(style_u); - let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0); - let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1)); - let placed = format!("{} translate({x} {y})", own_transform(&cx.css, style_u)); + let i = style_t.map_or(shared, |s| use_slot(cx, style_xml, slots, inst, shared, s, style_u, warnings)); + let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue }; + let placed = use_placement(cx, u, style_u); let text_xf = match style_t { Some(s) => own_transform(&cx.css, s), None => t.attribute("transform").unwrap_or("").to_string(), @@ -386,6 +388,186 @@ fn clip_uses( } } +/// Replace each `<use>` outside clip paths that shows a text it passes other properties on to by a +/// group holding that text's placeholder as the `<use>` shows it ([`use_slot`]). Every other +/// `<use>` of a text shows the text's own placeholder. +#[allow(clippy::too_many_arguments)] +fn text_uses( + svg: &str, + xml: &roxmltree::Document, + style_xml: &roxmltree::Document, + cx: &Ctx, + slot_of: &HashMap<roxmltree::NodeId, usize>, + slots: &mut TextSlots, + inst: &mut Instances, + edits: &mut Vec<(Range<usize>, String)>, + warnings: &mut Vec<String>, +) { + let is_use = |n: &XNode| { + n.is_element() + && n.tag_name().name() == "use" + && n.tag_name().namespace() == Some(SVG_NS) + && !n.ancestors().any(|a| a.is_element() && a.tag_name().name() == "clipPath") + }; + if !xml.descendants().any(|n| is_use(&n)) { + return; + } + let (ids, style_ids) = (first_ids(xml), first_ids(style_xml)); + for (u, style_u) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)) { + let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue }; + let Some(t) = ids.get(id) else { continue }; + let Some(&shared) = slot_of.get(&t.id()) else { continue }; + let Some(style_t) = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()) else { continue }; + let Some(q) = qualifier(svg, u) else { continue }; + let i = use_slot(cx, style_xml, slots, inst, shared, style_t, style_u, warnings); + let (Some(text), Some(paints)) = (slots.texts.get(i), inst.paints.get(i)) else { continue }; + if i == shared { + continue; + } + let mut out = String::new(); + use_group(&mut out, q, cx, u, style_u); + placeholder(&mut out, q, &cx.css, *t, style_t, &slots.prefix, i, text.marker, paints, false); + let _ = write!(out, "</{q}g>"); + edits.push((u.range(), out)); + } +} + +/// A text's slot, the parent of a `<use>` showing it and the properties that `<use>` sets itself. +type UseKey = (usize, Option<roxmltree::NodeId>, Vec<(String, String)>); + +/// The slots made for texts as `<use>` elements show them ([`use_slot`]). +#[derive(Default)] +struct Instances { + /// The `url(#…)` paints of each slot, by index. + paints: Vec<Vec<String>>, + /// The slot made for each [`UseKey`]; `None` where the text's own slot serves. + memo: HashMap<UseKey, Option<usize>>, + /// The nodes, text bytes and text path elements of the texts built so far, up to + /// [`MAX_INSTANCE_WORK`]. + work: usize, +} + +/// The most nodes, text bytes and elements of the paths `<textPath>`s follow that [`use_slot`] +/// builds texts from, all `<use>` elements together (the file is untrusted). Past it, a `<use>` +/// shows its text with the text's own properties. +const MAX_INSTANCE_WORK: usize = 1 << 20; + +/// The slot of text `style_t` (slot `shared`) as `<use>` `style_u` shows it. The text inherits +/// from the `<use>` there ([`Ctx::instance`]): when that changes it, it gets a new slot; otherwise +/// the result is `shared`. `<use>` elements with one parent that set the same properties share one. +#[allow(clippy::too_many_arguments)] +fn use_slot( + cx: &Ctx, + style_xml: &roxmltree::Document, + slots: &mut TextSlots, + inst: &mut Instances, + shared: usize, + style_t: XNode, + style_u: XNode, + warnings: &mut Vec<String>, +) -> usize { + // A `<use>` inside the text it shows would make the text inherit from itself. + if style_u.ancestors().any(|a| a == style_t) { + return shared; + } + let placing = |name: &str| matches!(name, "id" | "x" | "y" | "width" | "height" | "href" | "transform" | "style"); + let mut own: Vec<(String, String)> = style_u + .attributes() + .filter(|a| a.namespace().is_none() && !placing(a.name())) + .map(|a| (a.name().to_string(), a.value().to_string())) + .collect(); + own.extend(cx.css.declared(style_u).iter().filter(|(k, _)| k.as_str() != "transform").map(|(k, v)| (format!("style {k}"), v.clone()))); + own.sort(); + let key = (shared, style_u.parent_element().map(|p| p.id()), own); + if let Some(slot) = inst.memo.get(&key) { + return slot.unwrap_or(shared); + } + let label = style_t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); + let work = if inst.work < MAX_INSTANCE_WORK { + // Each build lays the text out along the path its `<textPath>` follows, so the path counts too. + style_t + .descendants() + .map(|n| if n.is_text() { n.text().map_or(0, str::len) } else { cx.path_elements(n).saturating_add(1) }) + .fold(0, usize::saturating_add) + } else { + 0 + }; + inst.work = inst.work.saturating_add(work); + if work == 0 || inst.work > MAX_INSTANCE_WORK { + inst.work = MAX_INSTANCE_WORK; + let w = format!( + "more than {} KiB of text and text paths shown through <use> elements: the rest show their text without the properties the <use> passes on", + MAX_INSTANCE_WORK >> 10 + ); + if !warnings.contains(&w) { + warnings.push(w); + } + return shared; + } + let shown = cx.instance(style_t, style_u); + let mut found = vec![]; + let built = if style_t.tag_name().name() == "flowRoot" { + shown.build_flow(style_t, &label, &mut found) + } else { + shown.build(style_xml, style_t, &label, &mut found) + }; + let differs = |(text, paints): &(PendingText, Vec<String>)| { + slots.texts.get(shared).is_none_or(|old| old.obj != text.obj || old.servers != text.servers || old.under != text.under) + || inst.paints.get(shared) != Some(paints) + }; + let slot = built.filter(differs).map(|(text, paints)| { + for w in found { + if !warnings.contains(&w) { + warnings.push(w); + } + } + slots.texts.push(text); + inst.paints.push(paints); + slots.texts.len() - 1 + }); + inst.memo.insert(key, slot); + slot.unwrap_or(shared) +} + +/// The transform `<use>` `u` places what it shows with: its own, then its `x`/`y` offset (in its +/// font size). `style_u` supplies its styles. +fn use_placement(cx: &Ctx, u: XNode, style_u: XNode) -> String { + let em = cx.font_size(style_u); + let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0); + let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1)); + format!("{} translate({x} {y})", own_transform(&cx.css, style_u)) +} + +/// Write the start tag of the group that takes `<use>` `u`'s place: its attributes in no namespace +/// other than its link, place and size, and as its `style` the properties rules and its `style` set +/// on it (from `style_u`), then its placement ([`use_placement`]) and its non-inherited properties +/// and `visibility`, so no rule that matches the group changes it. +fn use_group(out: &mut String, q: &str, cx: &Ctx, u: XNode, style_u: XNode) { + let _ = write!(out, "<{q}g"); + let placing = |name: &str| matches!(name, "style" | "href" | "x" | "y" | "width" | "height" | "transform"); + for a in u.attributes().filter(|a| a.namespace().is_none() && !placing(a.name())) { + let _ = write!(out, " {}=\"{}\"", a.name(), xml_escape(a.value())); + } + let declared = cx.css.declared(style_u); + let mut props: Vec<_> = declared.iter().filter(|(k, _)| k.as_str() != "transform").collect(); + props.sort(); + let mut style: String = props.into_iter().map(|(k, v)| format!("{k}:{v};")).collect(); + let _ = write!(style, "transform:{};", use_placement(cx, u, style_u)); + for (p, initial) in [ + ("opacity", "1"), + ("clip-path", "none"), + ("mask", "none"), + ("filter", "none"), + ("mix-blend-mode", "normal"), + ("isolation", "auto"), + ("display", "inline"), + ] { + let _ = write!(style, "{p}:{};", cx.css.own(style_u, p).as_deref().unwrap_or(initial)); + } + let _ = write!(style, "visibility:{}", cx.css.prop(style_u, "visibility").as_deref().unwrap_or("visible")); + let _ = write!(out, " style=\"{}\">", xml_escape(&style)); +} + /// Write a copy of `<use>` `u` in a `<defs>`: its attributes in no namespace, its link as `href`, /// and the properties set on it by rules and its `style` (from `style_u`) as its `style`. fn use_copy(out: &mut String, q: &str, css: &Styles, u: XNode, style_u: XNode) { @@ -455,11 +637,23 @@ fn clip_placeholder(out: &mut String, q: &str, id: &str, transform: &str, props: let _ = write!(out, " d=\"M{} {}H{}V{}H{}Z\"/>", b.x0, b.y0, b.x1, b.y1, b.x0); } -/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`. +/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`, with the text's +/// id when `with_id`. #[allow(clippy::too_many_arguments)] -fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode, prefix: &str, index: usize, b: Rect, paints: &[String]) { +fn placeholder( + out: &mut String, + q: &str, + css: &Styles, + t: XNode, + style_t: XNode, + prefix: &str, + index: usize, + b: Rect, + paints: &[String], + with_id: bool, +) { let _ = write!(out, "<{q}g"); - for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"] { + for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"].into_iter().skip(usize::from(!with_id)) { if let Some(v) = t.attribute(a) { let _ = write!(out, " {a}=\"{}\"", xml_escape(v)); } @@ -488,25 +682,41 @@ fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode /// Computed text properties of the document's elements. struct Ctx { - css: Styles, + css: Rc<Styles>, /// Pixels per inch absolute lengths convert at ([`super::RootUnits::dpi`]). dpi: f64, /// Viewport size in user units (the base of `x`/`y` percentages). viewport: (f64, f64), + /// For the styles of one `<use>` instance: the element the `<use>` shows and the `<use>`. The + /// shown element inherits from the `<use>` rather than from its parent (SVG 2 §5.6, + /// https://www.w3.org/TR/SVG2/struct.html#UseElement). + host: Option<(roxmltree::NodeId, roxmltree::NodeId)>, sizes: RefCell<HashMap<roxmltree::NodeId, f64>>, weights: RefCell<HashMap<roxmltree::NodeId, u16>>, elems: RefCell<HashMap<roxmltree::NodeId, Rc<ElemStyle>>>, + /// The path each `<textPath>` read so far follows ([`Self::text_path`]); `None` where it has + /// no usable one. The styles of a `<use>` instance share it, so each path is found and parsed + /// once however many `<use>` elements show its text. + paths: Rc<RefCell<HashMap<roxmltree::NodeId, Option<Rc<BezPath>>>>>, +} + +#[cfg(test)] +thread_local! { + /// How many times [`Ctx::read_text_path`] has read a `<textPath>`'s path on this thread. + static TEXT_PATH_READS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) }; } impl Ctx { fn new(xml: &roxmltree::Document, dpi: f64) -> Self { let mut cx = Self { - css: Styles::new(xml), + css: Rc::new(Styles::new(xml)), dpi, viewport: (100.0, 100.0), + host: None, sizes: RefCell::default(), weights: RefCell::default(), elems: RefCell::default(), + paths: Rc::default(), }; let root = xml.root_element(); let dim = |a: &str| root.attribute(a).and_then(|v| cx.length(v, DEFAULT_FONT_SIZE, 0.0)).filter(|v| *v > 0.0); @@ -517,6 +727,38 @@ impl Ctx { cx } + /// The styles of element `shown` as `<use>` `u` shows it. + fn instance(&self, shown: XNode, u: XNode) -> Self { + Self { + css: self.css.clone(), + dpi: self.dpi, + viewport: self.viewport, + host: Some((shown.id(), u.id())), + sizes: RefCell::default(), + weights: RefCell::default(), + elems: RefCell::default(), + paths: self.paths.clone(), + } + } + + /// The element `n` inherits from: its parent, or the `<use>` for the element it shows. + fn parent<'a, 'i>(&self, n: XNode<'a, 'i>) -> Option<XNode<'a, 'i>> { + match self.host { + Some((shown, u)) if n.id() == shown => n.document().get_node(u), + _ => n.parent_element(), + } + } + + /// Element `n` and the elements it inherits from, nearest first. + fn lineage<'a, 'i>(&self, n: XNode<'a, 'i>) -> impl Iterator<Item = XNode<'a, 'i>> { + std::iter::successors(Some(n), |a| self.parent(*a)) + } + + /// An inherited property: the nearest specified value other than `inherit`. + fn prop(&self, n: XNode, name: &str) -> Option<String> { + self.lineage(n).find_map(|a| self.css.own(a, name).filter(|v| v != "inherit")) + } + /// A length in user units: absolute units convert at [`Self::dpi`], `em` and `ex` are relative /// to `em` (the font size) and percentages to `pct`. fn length(&self, v: &str, em: f64, pct: f64) -> Option<f64> { @@ -559,7 +801,7 @@ impl Ctx { if let Some(s) = self.sizes.borrow().get(&n.id()) { return *s; } - let parent = n.parent_element().map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p)); + let parent = self.parent(n).map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p)); let s = self.css.own(n, "font-size").and_then(|v| self.parse_font_size(&v, parent)).unwrap_or(parent); self.sizes.borrow_mut().insert(n.id(), s); s @@ -570,7 +812,7 @@ impl Ctx { if let Some(w) = self.weights.borrow().get(&n.id()) { return *w; } - let parent = n.parent_element().map_or(400, |p| self.weight(p)); + let parent = self.parent(n).map_or(400, |p| self.weight(p)); let w = match self.css.own(n, "font-weight").as_deref() { Some("normal") => 400, Some("bold") => 700, @@ -594,12 +836,12 @@ impl Ctx { /// A fill or stroke: a ready paint, or a `url(#…)` paint for usvg to resolve. fn paint(&self, n: XNode, prop: &str) -> (Paint, Option<String>) { let initial = if prop == "fill" { Paint::solid(Color::BLACK) } else { Paint::None }; - let Some(v) = self.css.prop(n, prop) else { return (initial, None) }; + let Some(v) = self.prop(n, prop) else { return (initial, None) }; match svgtypes::Paint::from_str(&v) { Ok(svgtypes::Paint::None) => (Paint::None, None), Ok(svgtypes::Paint::Color(c)) => (solid(c), None), Ok(svgtypes::Paint::CurrentColor) => { - (self.css.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None) + (self.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None) } Ok(svgtypes::Paint::FuncIRI(..)) => (Paint::None, Some(v)), _ => (initial, None), @@ -613,7 +855,7 @@ impl Ctx { } let css = &self.css; let mut st = CharStyle::default(); - if let Some(f) = css.prop(n, "font-family") { + if let Some(f) = self.prop(n, "font-family") { let fam = f.split(',').next().unwrap_or("").trim().trim_matches(|c| c == '\'' || c == '"').to_string(); if !fam.is_empty() { st.font_family = fam; @@ -621,45 +863,46 @@ impl Ctx { } let size = self.font_size(n); st.size = size; - let italic = css.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique"); + let italic = self.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique"); st.font_style = style_name(self.weight(n), italic); // Inherited: `inherit`, `unset`, `revert` and a value CSS ignores leave the one inherited. let inherits = |v: &String| ["inherit", "unset", "revert", "revert-layer"].iter().any(|k| v.trim().eq_ignore_ascii_case(k)); let own = |a: XNode| css.own(a, "font-variation-settings").filter(|v| !inherits(v)).and_then(|v| variation_settings(&v)); - st.font_axes = n.ancestors().filter(|a| a.is_element()).find_map(own).unwrap_or_default(); + st.font_axes = self.lineage(n).find_map(own).unwrap_or_default(); let (fill, mut fill_url) = self.paint(n, "fill"); st.fill = fill; let (stroke, mut stroke_url) = self.paint(n, "stroke"); if !stroke.is_none() || stroke_url.is_some() { st.stroke = stroke; - st.stroke_width = css.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0); - st.stroke_cap = match css.prop(n, "stroke-linecap").as_deref() { + st.stroke_width = self.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0); + st.stroke_cap = match self.prop(n, "stroke-linecap").as_deref() { Some("round") => LineCap::Round, Some("square") => LineCap::Square, _ => LineCap::Butt, }; - st.stroke_join = match css.prop(n, "stroke-linejoin").as_deref() { + st.stroke_join = match self.prop(n, "stroke-linejoin").as_deref() { Some("round") => LineJoin::Round, Some("bevel") => LineJoin::Bevel, _ => LineJoin::Miter, }; st.stroke_miter_limit = - css.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::<f64>().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0)); - let pattern: Vec<f64> = css.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect()); - let offset = css.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0); + self.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::<f64>().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0)); + let pattern: Vec<f64> = + self.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect()); + let offset = self.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0); st.stroke_dash = Some(Dash { pattern, offset, align_corners: false }).filter(Dash::is_dashed); } - if let Some(v) = css.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size)) + if let Some(v) = self.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size)) && size > 0.0 { st.tracking = v / size * 1000.0; } - if let Some(v) = css.prop(n, "kerning").and_then(|v| self.length(&v, size, size)) + if let Some(v) = self.prop(n, "kerning").and_then(|v| self.length(&v, size, size)) && size > 0.0 { st.kerning = Some(v / size * 1000.0); } - if let Some(d) = css.prop(n, "text-decoration") { + if let Some(d) = self.prop(n, "text-decoration") { st.underline = d.contains("underline"); st.strikethrough = d.contains("line-through"); } @@ -672,11 +915,11 @@ impl Ctx { }; let parent = if n.tag_name().name() == "text" { 0.0 } else { n.parent_element().map_or(0.0, |p| self.elem(p).style.baseline_shift) }; st.baseline_shift = parent + own; - if css.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") { + if self.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") { (st.fill, st.stroke, fill_url, stroke_url) = (Paint::None, Paint::None, None, None); } - let word_spacing = css.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0); - let order = css.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order; + let word_spacing = self.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0); + let order = self.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order; let at = |k| order.iter().position(|o| *o == k); let stroke_first = at(svgtypes::PaintOrderKind::Stroke) < at(svgtypes::PaintOrderKind::Fill); let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing, stroke_first }); @@ -761,8 +1004,27 @@ impl Ctx { } } - /// The path a `<textPath>` follows, in the text's user space. - fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<BezPath> { + /// The path a `<textPath>` follows, in the text's user space. It is read from the file once + /// ([`Self::paths`]). + fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<Rc<BezPath>> { + if let Some(known) = self.paths.borrow().get(&tp.id()) { + return known.clone(); + } + let path = self.read_text_path(xml, tp).map(Rc::new); + self.paths.borrow_mut().insert(tp.id(), path.clone()); + path + } + + /// How many elements the path of `<textPath>` `tp` has, when a text built so far follows it + /// ([`Self::text_path`]); 0 for any other node. + fn path_elements(&self, tp: XNode) -> usize { + self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.elements().len()) + } + + /// Find and parse the path `<textPath>` `tp` follows. + fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<BezPath> { + #[cfg(test)] + TEXT_PATH_READS.set(TEXT_PATH_READS.get() + 1); let mut bp = match tp.attribute("path") { Some(d) => BezPath::from_svg(d).ok()?, None => { @@ -974,7 +1236,7 @@ impl Ctx { .collect(); let under = stroke_under(&mut cells, label, warnings); - let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.css.prop(c.1, "text-anchor")).as_deref() { + let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.prop(c.1, "text-anchor")).as_deref() { Some("middle") => 0.5, Some("end") => 1.0, _ => 0.0, @@ -987,7 +1249,7 @@ impl Ctx { None } }); - let vertical = self.css.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); + let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings); let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); let mut breaks: Vec<Break> = vec![]; @@ -1059,7 +1321,7 @@ impl Ctx { } // SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first // strong character: pinned (on every paragraph) when that would read as right to left. - if self.css.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") { + if self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") { obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft)); } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight)); @@ -1400,3 +1662,6 @@ fn assemble(cells: &[Cell], breaks: &[Break]) -> (Runs, Vec<usize>) { } (out, bytes) } + +#[cfg(test)] +mod tests; diff --git a/crates/svg/src/import/text/tests.rs b/crates/svg/src/import/text/tests.rs new file mode 100644 index 0000000000..1508997dcc --- /dev/null +++ b/crates/svg/src/import/text/tests.rs @@ -0,0 +1,24 @@ +use super::*; + +/// The texts [`prepare`] builds from `body` and how many times it read a `<textPath>`'s path. +fn prepared(body: &str) -> (usize, u64, Vec<String>) { + let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">{body}</svg>"#); + let xml = roxmltree::Document::parse(&svg).unwrap(); + let mut warnings = vec![]; + let before = TEXT_PATH_READS.get(); + let (_, slots) = prepare(&svg, &xml, None, 96.0, &mut warnings); + (slots.texts.len(), TEXT_PATH_READS.get() - before, warnings) +} + +/// Every `<use>` of a text on a path follows the path the text's own build read. +#[test] +fn uses_of_a_text_on_a_path_read_the_path_once() { + let uses: String = (101..=150).map(|i| format!(r##"<use href="#t" font-size="{i}"/>"##)).collect(); + let (texts, reads, warnings) = + prepared(&format!(r##"<defs><path id="p" d="M0 20L150 20"/><text id="t"><textPath href="#p">Path</textPath></text></defs>{uses}"##)); + assert_eq!(texts, 51, "the text and one per <use>: {warnings:?}"); + assert_eq!(reads, 1); + // A path with no usable data is not read again either. + let (texts, reads, _) = prepared(&format!(r##"<defs><text id="t"><textPath href="#none">Path</textPath></text></defs>{uses}"##)); + assert_eq!((texts, reads), (51, 1)); +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 8a447f6c38..066392bb87 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -74,8 +74,8 @@ //! * A `<g id>` layer (top-level, or inside one) that isn't displayed (`display: none`) comes back //! as a hidden layer; other undisplayed objects as hidden objects, unless a `<use>` refers to //! them. -//! * `spreadMethod` reflect and repeat are expanded into stops over the painted area (at most -//! 64 periods). +//! * `spreadMethod` reflect and repeat become the gradient's spread, Reflect or Repeat, its stops +//! running once along the vector. //! * `<image>` files are read through [`ImportOptions`]: rasters stay linked, SVG files (and SVG //! `data:` URLs) become art (without their text), missing ones a placeholder keeping the link. //! * Text lines after the first start at the first line's x; absolute positions inside type on a diff --git a/crates/svg/tests/it/clip_text.rs b/crates/svg/tests/it/clip_text.rs index 07773dd5f8..a2dcb46156 100644 --- a/crates/svg/tests/it/clip_text.rs +++ b/crates/svg/tests/it/clip_text.rs @@ -183,6 +183,20 @@ fn a_use_of_a_text_in_a_clip_path_clips_where_the_use_places_it() { assert!(glyphs.x0 >= 200.0 - 1e-6 && glyphs.y1 <= 100.0 + 1e-6 && glyphs.y0 > 40.0, "{glyphs:?}"); } +#[test] +fn a_use_of_a_text_in_a_clip_path_passes_its_properties_on_to_the_text() { + let d = open( + r##"<defs><text id="t" x="0" y="0">HI</text></defs> + <clipPath id="c" font-weight="bold"><use href="#t" x="20" y="150" font-size="100"/></clipPath> + <rect width="400" height="300" clip-path="url(#c)"/>"##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(near(t.runs[0].style.size, 100.0, 1e-9), "{}", t.runs[0].style.size); + assert_eq!(t.runs[0].style.font_style, "Bold"); + assert!(clip_bounds(clip).height() > 50.0, "{:?}", clip_bounds(clip)); +} + #[test] fn a_clip_path_on_a_use_of_a_text_is_in_the_use_s_user_space() { // The text's transform moves its glyphs to x 200…; the `<use>`'s clip keeps x < 250 of its own diff --git a/crates/svg/tests/it/css_rules.rs b/crates/svg/tests/it/css_rules.rs index 0c1af2d287..2ca6dcc30b 100644 --- a/crates/svg/tests/it/css_rules.rs +++ b/crates/svg/tests/it/css_rules.rs @@ -335,6 +335,45 @@ fn diamond_gradients_give_css_radial_gradients_through_their_corners() { ); } +/// A linear or radial gradient that repeats or reflects is a repeating CSS gradient of one cycle (a +/// run, or for Reflect a run and its mirror); a conic one has its stops repeated around the turn. +#[test] +fn spread_gradients_give_repeating_css_gradients() { + use vectorcraft_color::GradientSpread; + let mut d = doc(); + let mut css = |kind, spread, geom: GradientGeom| { + let mut g = GradientPaint::new(Gradient { kind, spread, repeats: 4, ..Gradient::default() }); + g.geom = Some(geom); + let mut n = rect(&mut d, (0.0, 0.0, 100.0, 60.0), 0.0); + n.appearance = Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0).into(); + let id = add(&mut d, n); + prop(&one(&d, id, &CssOptions::default()), "background-image").map(str::to_string) + }; + // Left to right across the box: a run is a quarter of it. + let across = GradientGeom { start: Point::new(0.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }; + assert_eq!( + css(GradientKind::Linear, GradientSpread::Repeat, across).as_deref(), + Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%)") + ); + assert_eq!( + css(GradientKind::Linear, GradientSpread::Reflect, across).as_deref(), + Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%, #000000 25%, #ffffff 50%)") + ); + let ring = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None }; + assert_eq!( + css(GradientKind::Radial, GradientSpread::Reflect, ring).as_deref(), + Some("repeating-radial-gradient(circle 20px at 40px 30px, #ffffff 0%, #000000 50%, #000000 50%, #ffffff 100%)") + ); + // A diamond's stand-in, the ellipse through its corners, repeats with each run of squares. + assert_eq!( + css(GradientKind::Diamond, GradientSpread::Repeat, ring).as_deref(), + Some("repeating-radial-gradient(circle 10px at 40px 30px, #ffffff 0%, #000000 100%)") + ); + let conic = css(GradientKind::Conic, GradientSpread::Repeat, ring).unwrap(); + assert!(conic.starts_with("conic-gradient(from 90deg at 40px 30px, #ffffff 0%, #000000 25%, #ffffff 25%"), "{conic}"); + assert!(conic.ends_with("#000000 100%)"), "{conic}"); +} + #[test] fn shadows_and_unsupported_art() { let mut d = doc(); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 8b0d0338c6..b25dc23678 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -191,6 +191,89 @@ fn hidden_text_is_skipped_and_use_instances_are_kept() { assert!(t[1].xf.translation().to_point().distance(Point::new(50.0, 30.0)) < 1e-9); } +/// SVG 2 §5.6 (https://www.w3.org/TR/SVG2/struct.html#UseElement): the element a `<use>` shows +/// inherits from the `<use>`, not from where it is defined. +#[test] +fn a_use_of_a_text_passes_its_properties_on_to_the_text() { + let d = open( + r##"<defs font-size="30"><text id="t" y="50">HI</text></defs> + <use href="#t" font-size="100" fill="red"/><g font-weight="bold"><use href="#t" x="200"/></g> + <use href="#t" y="200" opacity="0.5" style="font-size:20px"/>"##, + ); + let t = texts(&d); + assert_eq!(t.len(), 3, "{t:?}"); + let s: Vec<CharStyle> = t.iter().map(|t| t.first_style()).collect(); + assert!(close(s[0].size, 100.0, 1e-9), "{}", s[0].size); + assert_eq!(s[0].fill, Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0))); + assert!(close(s[1].size, 12.0, 1e-9), "the defaults, not the <defs>'s 30: {}", s[1].size); + assert_eq!(s[1].font_style, "Bold"); + assert_eq!(s[1].fill, Paint::solid(vectorcraft_color::Color::BLACK)); + assert!(close(s[2].size, 20.0, 1e-9), "{}", s[2].size); + assert!(t[0].xf.translation().to_point().distance(Point::new(0.0, 50.0)) < 1e-9, "{:?}", t[0].xf); + assert!(t[1].xf.translation().to_point().distance(Point::new(200.0, 50.0)) < 1e-9, "{:?}", t[1].xf); + assert!(t[2].xf.translation().to_point().distance(Point::new(0.0, 250.0)) < 1e-9, "{:?}", t[2].xf); + // The `<use>`'s opacity stays on the object it becomes. + let faded: Vec<&Node> = art(&d).into_iter().filter(|n| (n.opacity - 0.5).abs() < 1e-6).collect(); + assert_eq!(faded.len(), 1, "{faded:?}"); + // The text's own properties win over the `<use>`'s. + let d = open(r##"<defs><text id="t" y="50" font-size="20" fill="blue">HI</text></defs><use href="#t" font-size="100" fill="red"/>"##); + let s = only_text(&d).first_style(); + assert!(close(s.size, 20.0, 1e-9), "{}", s.size); + assert_eq!(s.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 255))); +} + +/// A `<use>` inside the text it shows does not make the text inherit from itself. +#[test] +fn a_use_inside_the_text_it_shows_opens() { + let d = open(r##"<text id="t" y="50" font-size="20">HI<use href="#t" font-size="100"/><tspan><use href="#t"/></tspan></text>"##); + assert!(texts(&d).iter().all(|t| close(t.first_style().size, 20.0, 1e-9)), "{:?}", texts(&d)); +} + +/// Past 1 MiB of text built for `<use>` elements, the rest show their text with its own properties. +#[test] +fn uses_of_a_long_text_stop_taking_their_properties_past_the_limit() { + let long = "a".repeat(350_000); + let uses: String = (1..=4).map(|i| format!(r##"<use href="#t" font-size="{}"/>"##, i * 10)).collect(); + let (d, w) = import_with_report(&svg(&format!(r##"<defs><text id="t" y="50">{long}</text></defs>{uses}"##))).unwrap(); + let sizes: Vec<f64> = texts(&d).iter().map(|t| t.first_style().size).collect(); + assert_eq!(sizes.len(), 4, "{sizes:?}"); + assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}"); + assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}"); + assert_eq!(w.iter().filter(|w| w.contains("KiB of text and text paths shown through <use> elements")).count(), 1, "{w:?}"); +} + +/// The path a text follows counts toward that limit: each `<use>` of the text lays it out along +/// the whole path again. +#[test] +fn uses_of_a_text_on_a_long_path_stop_taking_their_properties_past_the_limit() { + let d = format!("M0 0{}", "h1".repeat(350_000)); + let uses: String = (1..=4).map(|i| format!(r##"<use href="#t" font-size="{}"/>"##, i * 10)).collect(); + let body = format!(r##"<defs><path id="p" d="{d}"/><text id="t"><textPath href="#p">HI</textPath></text></defs>{uses}"##); + let (d, w) = import_with_report(&svg(&body)).unwrap(); + let sizes: Vec<f64> = texts(&d).iter().map(|t| t.first_style().size).collect(); + assert_eq!(sizes.len(), 4, "{sizes:?}"); + assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}"); + assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}"); + assert_eq!(w.iter().filter(|w| w.contains("KiB of text and text paths shown through <use> elements")).count(), 1, "{w:?}"); + // The same text on a short path is within the limit however many `<use>` elements show it. + let uses: String = (1..=300).map(|i| format!(r##"<use href="#t" font-size="{i}"/>"##)).collect(); + let body = format!(r##"<defs><path id="p" d="M0 20L150 20"/><text id="t"><textPath href="#p">HI</textPath></text></defs>{uses}"##); + let (d, w) = import_with_report(&svg(&body)).unwrap(); + assert!(texts(&d).iter().enumerate().all(|(i, t)| close(t.first_style().size, (i + 1) as f64, 1e-9)), "{w:?}"); + assert!(!w.iter().any(|w| w.contains("shown through <use> elements")), "{w:?}"); +} + +/// Many `<use>` elements of one text that inherit the same properties each get it. +#[test] +fn many_uses_of_a_text_in_one_group_all_take_its_properties() { + let uses: String = (0..200).map(|i| format!(r##"<use href="#t" x="{i}"/>"##)).collect(); + let d = open(&format!(r##"<defs><text id="t" y="50">HI</text></defs><g font-size="40">{uses}</g>"##)); + let t = texts(&d); + assert_eq!(t.len(), 200); + assert!(t.iter().all(|t| close(t.first_style().size, 40.0, 1e-9))); + assert!(t[199].xf.translation().to_point().distance(Point::new(199.0, 50.0)) < 1e-9, "{:?}", t[199].xf); +} + #[test] fn text_path_becomes_type_on_a_path() { let d = open( diff --git a/crates/svg/tests/it/main.rs b/crates/svg/tests/it/main.rs index ac0b2442ec..b241f2e75b 100644 --- a/crates/svg/tests/it/main.rs +++ b/crates/svg/tests/it/main.rs @@ -32,6 +32,7 @@ mod options; mod prop_svg; mod raster_paints; mod roundtrip; +mod spread; mod stroke_export; mod svg_glyphs; mod symbols_layers; diff --git a/crates/svg/tests/it/spread.rs b/crates/svg/tests/it/spread.rs new file mode 100644 index 0000000000..9a7cc0739d --- /dev/null +++ b/crates/svg/tests/it/spread.rs @@ -0,0 +1,132 @@ +//! Gradients that repeat or reflect: SVG export writes `spreadMethod` over one run of the stops +//! (a conic's wedges and a diamond's quarters repeat with them), and an independent renderer +//! draws the file as the canvas draws the gradient; SVG import reads `spreadMethod` as the +//! gradient's spread; CSS writes repeating gradients. + +use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientSpread, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect, Vec2, shapes}; +use vectorcraft_svg::{ExportOptions, export_full, import}; +use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard}; + +/// The SVG rendered by resvg on white at 1 px/pt. +fn resvg_render(svg: &str, w: u32, h: u32) -> Image { + let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse"); + let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap(); + pm.fill(resvg::tiny_skia::Color::WHITE); + resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut()); + Image { width: w, height: h, rgba: pm.data().to_vec() } +} + +/// A red-to-blue gradient of `kind` that spreads by `spread` 3 times, placed at `geom`. +fn paint(kind: GradientKind, spread: GradientSpread, geom: GradientGeom) -> Paint { + 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); + Paint::Gradient(Box::new(g)) +} + +/// A 200 × 160 document holding a rectangle painted with `fill` and `stroke`. +fn doc(fill: Paint, stroke: Paint, width: f64) -> Document { + let mut d = Document::new(200.0, 160.0); + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0)), Appearance::basic(fill, stroke, width)); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn cases() -> Vec<(&'static str, GradientKind, GradientGeom)> { + let c = Point::new(100.0, 80.0); + let geom = GradientGeom { start: c, end: c + Vec2::new(50.0, -20.0), aspect: 0.7, focal: None }; + vec![ + ("linear", GradientKind::Linear, GradientGeom { start: Point::new(60.0, 60.0), end: Point::new(150.0, 90.0), aspect: 1.0, focal: None }), + ("radial", GradientKind::Radial, geom), + ("off-center radial", GradientKind::Radial, GradientGeom { focal: Some(c + Vec2::new(-12.0, 10.0)), ..geom }), + ("conic", GradientKind::Conic, geom), + ("diamond", GradientKind::Diamond, geom), + ] +} + +/// Each kind's fill and stroke, repeating and reflecting, exported and drawn by resvg, looks as the +/// canvas paints it, inside the vector and past its ends. +#[test] +fn an_independent_renderer_draws_the_export_as_the_canvas_does() { + for (what, kind, geom) in cases() { + for spread in [GradientSpread::Repeat, GradientSpread::Reflect] { + for stroke in [false, true] { + let d = if stroke { doc(Paint::None, paint(kind, spread, geom), 14.0) } else { doc(paint(kind, spread, geom), Paint::None, 0.0) }; + let o = export_full(&d, &ExportOptions::default(), None); + if matches!(kind, GradientKind::Linear | GradientKind::Radial | GradientKind::Diamond) { + let attr = if spread == GradientSpread::Repeat { "spreadMethod=\"repeat\"" } else { "spreadMethod=\"reflect\"" }; + assert!(o.svg.contains(attr), "{what} {spread:?}: {}", o.svg); + } + // Hard edges (where Repeat starts the stops again, and between a conic's wedges) + // differ by a pixel's antialiasing at most. + assert_similar(&render_artboard(&d), &resvg_render(&o.svg, 200, 160), 10.0, 0.03); + } + } + } +} + +/// One run lies along the vector: a linear gradient's `x2` is a third of the way along it. +#[test] +fn the_written_vector_holds_one_run() { + let geom = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(120.0, 50.0), aspect: 1.0, focal: None }; + let o = export_full(&doc(paint(GradientKind::Linear, GradientSpread::Repeat, geom), Paint::None, 0.0), &ExportOptions::default(), None); + assert!(o.svg.contains("x1=\"30\" y1=\"50\" x2=\"60\" y2=\"50\" spreadMethod=\"repeat\""), "{}", o.svg); +} + +/// On type characters, where SVG writes a conic as a linear gradient along its vector, the runs +/// lie along that vector: a third of it each. +#[test] +fn a_conic_on_characters_repeats_along_its_vector() { + use vectorcraft_doc::{CharStyle, TextObject}; + let geom = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(120.0, 50.0), aspect: 1.0, focal: None }; + let Paint::Gradient(g) = paint(GradientKind::Conic, GradientSpread::Repeat, geom) else { panic!("a gradient") }; + let st = CharStyle { fill: Paint::Gradient(g), size: 48.0, ..CharStyle::default() }; + let mut d = Document::new(200.0, 160.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 80.0), "Fan", st)))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let o = export_full(&d, &ExportOptions::default(), None); + assert!(o.svg.contains("x1=\"30\" y1=\"50\" x2=\"60\" y2=\"50\" spreadMethod=\"repeat\""), "{}", o.svg); +} + +/// `spreadMethod` reads as the gradient's spread, with its stops and vector as written. +#[test] +fn import_reads_spread_method_as_the_spread() { + for (attr, want) in [("repeat", GradientSpread::Repeat), ("reflect", GradientSpread::Reflect), ("pad", GradientSpread::Pad)] { + let svg = format!( + r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120"><defs><linearGradient id="g" x1="40" y1="0" x2="70" y2="0" gradientUnits="userSpaceOnUse" spreadMethod="{attr}"><stop offset="0.2" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient></defs><rect x="10" y="10" width="180" height="100" fill="url(#g)"/></svg>"## + ); + let d = import(&svg).unwrap(); + let mut found = None; + d.walk(|n| { + if let (NodeKind::Path { .. }, Paint::Gradient(g)) = (&n.kind, n.appearance.fill_paint()) { + found = Some(g.clone()); + } + }); + let g = found.unwrap_or_else(|| panic!("{attr}: no gradient")); + assert_eq!((g.gradient.spread, g.gradient.repeat_count(), g.gradient.stops.len()), (want, 1, 2), "{attr}"); + let geom = g.geom.unwrap(); + assert_eq!((geom.start, geom.end), (Point::new(40.0, 0.0), Point::new(70.0, 0.0)), "{attr}"); + } +} + +/// Exported and read back, a repeating gradient keeps its spread; its count becomes part of the +/// vector, so it looks the same. +#[test] +fn a_spread_survives_export_and_import() { + let geom = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(150.0, 90.0), aspect: 1.0, focal: None }; + let d = doc(paint(GradientKind::Linear, GradientSpread::Reflect, geom), Paint::None, 0.0); + let o = export_full(&d, &ExportOptions::default(), None); + let back = import(&o.svg).unwrap(); + let mut spreads = vec![]; + back.walk(|n| { + if let Paint::Gradient(g) = n.appearance.fill_paint() { + spreads.push(g.gradient.spread); + } + }); + assert_eq!(spreads, [GradientSpread::Reflect]); + assert_similar(&render_artboard(&d), &render_artboard(&back), 4.0, 0.01); +} diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index e858f1ba08..4bb4b71d82 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -177,6 +177,19 @@ fn proportional_metrics_write_palt() { assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]); } +/// A feature with a value (`salt=2`) is written as that value, `-tag` as 0, a plain tag as 1; a +/// tag whose value is not a number is left out. +#[test] +fn feature_values_write_their_number() { + let valued = CharStyle { features: vec!["salt=2".into(), "-liga".into(), "ss01".into(), "swsh=12".into(), "salt=x".into()], ..style(12.0) }; + let d = area_doc(vec![run("ab ", style(12.0)), run("cd", valued)], 280.0, Justify::Left); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert_eq!(tags.len(), 2, "{svg}"); + assert!(tags[1].contains("font-feature-settings:"salt" 2, "liga" 0, "ss01" 1, "swsh" 12"), "{}", tags[1]); + assert!(!tags[1].contains("salt=x") && !tags[0].contains("salt"), "{svg}"); +} + /// Character Direction: live text writes each run set Left to Right as a bidi embedding /// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines. #[test] diff --git a/crates/svg/tests/it/title_control_chars.rs b/crates/svg/tests/it/title_control_chars.rs index 3318ed346f..2318eebc67 100644 --- a/crates/svg/tests/it/title_control_chars.rs +++ b/crates/svg/tests/it/title_control_chars.rs @@ -69,3 +69,29 @@ fn dublin_core_metadata_control_chars_are_filtered() { assert!(svg.contains("<dc:subject>kw</dc:subject>")); assert!(svg.contains("<dc:rights>(c) me</dc:rights>")); } + +/// The root's `<title>` is the File Info title of the imported document ([`Document::info_title`]); +/// its name stays what `Document::new` gave it, and a shape's `<title>` is that shape's tooltip. +#[test] +fn the_roots_title_is_the_file_info_title_not_the_documents_name() { + let svg = |inner: &str| { + format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">{inner}<rect width="5" height="5"><title>Tip"#) + }; + let d = import(&svg("\n Access\n paths ")).unwrap(); + assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", Some("Access paths"), "Access paths")); + for none in [svg(""), svg(" ")] { + let d = import(&none).unwrap(); + assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", None, "Untitled-1")); + } +} + +/// Exports write the File Info title, where the document has one, in place of its name. +#[test] +fn exports_write_the_file_info_title() { + let mut d = Document::new(200.0, 200.0); + d.title = "box.svg".into(); + d.metadata.title = Some("Access paths".into()); + let svg = export(&d, &ExportOptions { metadata: true, ..ExportOptions::default() }); + assert!(svg.contains("Access paths") && svg.contains("Access paths"), "{svg}"); + assert!(!svg.contains("box.svg"), "{svg}"); +} diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index 10b0608001..153ede8f02 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -2,7 +2,8 @@ use proptest::prelude::*; use serde_json::{Value, json}; -use vectorcraft_engine::{Session, command_specs}; +use vectorcraft_engine::cmd::{Location, key_location, location_in, location_keys}; +use vectorcraft_engine::{CommandSpec, Session, command_specs}; use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath}; use crate::fixtures::{art_nodes, top_level_ids}; @@ -473,8 +474,11 @@ pub fn all_param_keys() -> Vec { keys.into_iter().collect() } -/// Keys of a params doc string like `{x, y, width?, radius?: pt} → {id}` (only the part before `→`). +/// Keys of a params doc string like `{x, y, width?, radius?: pt} → {id}` (only the part before `→`), +/// with every key the engine reads in it ([`vectorcraft_engine::cmd::params::doc_keys`]: `path | name`, +/// `folder? (…)`). pub fn param_keys(doc: &str) -> Vec { + let described = vectorcraft_engine::cmd::params::doc_keys(doc).map(|p| p.keys).unwrap_or_default(); let doc = doc.split('→').next().unwrap_or(""); let chars: Vec = doc.chars().collect(); let mut out = vec![]; @@ -497,55 +501,114 @@ pub fn param_keys(doc: &str) -> Vec { i += 1; } } + out.extend(described.into_iter().map(str::to_string)); out.sort(); out.dedup(); out } -/// Parameter-name keys that name a file or folder a command may write: fuzzing these with -/// arbitrary strings could write files anywhere, so callers substitute a temp path. +/// The keys of command `spec`: those of its params doc ([`param_keys`]), of its schema and its file +/// and folder keys ([`location_keys`]). +pub fn command_param_keys(spec: &CommandSpec) -> Vec { + let mut keys = param_keys(spec.params); + keys.extend(spec.schema.into_iter().flat_map(|s| s.params.iter().map(|p| p.name.to_string()))); + keys.extend(location_keys(spec).into_iter().map(|(k, _)| k.to_string())); + keys.sort(); + keys.dedup(); + keys +} + +/// Parameter-name keys that name a file or folder in some command ([`key_location`]): fuzzing these +/// with arbitrary strings could write files anywhere, so callers substitute a temp path. pub fn is_path_key(k: &str) -> bool { - matches!(k, "path" | "diffPath") || is_folder_key(k) + key_location(k).is_some() } -/// Path keys that name a folder a command creates or writes files into. +/// Path keys that name a folder a command reads, creates or writes files into ([`key_location`]): +/// `folder`, `folders`, `dir`, `directory`, `destinationFolder` and `sourceFolder`. pub fn is_folder_key(k: &str) -> bool { - matches!(k, "folder" | "destinationFolder") + key_location(k) == Some(Location::Folder) } -/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for -/// a folder key. +/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it (in +/// the same temp folder) for a folder key. pub fn safe_location(k: &str, safe_path: &str) -> String { - if is_folder_key(k) { format!("{safe_path}-folder") } else { safe_path.to_string() } + location(key_location(k).unwrap_or(Location::File), safe_path) } -/// `v`, or the temp location for `k` when `k` is a path key and `v` is a string. +/// The temp location for a key that names `l`: `safe_path` for a file, a folder next to it (in +/// the same temp folder) for a folder. +fn location(l: Location, safe_path: &str) -> String { + if l == Location::Folder { format!("{safe_path}-folder") } else { safe_path.to_string() } +} + +/// `v` with every string in it that can name a place on disk sent to the temp location: `v` +/// itself and the members of arrays in it, at any depth, when `kind` is `Some`, and in an object +/// at any depth, the strings under a path key ([`is_path_key`]). Other values keep their junk +/// shape. +fn redirect(kind: Option, v: &Value, safe_path: &str) -> Value { + match v { + Value::String(_) => kind.map_or_else(|| v.clone(), |l| json!(location(l, safe_path))), + Value::Array(a) => Value::Array(a.iter().map(|x| redirect(kind, x, safe_path)).collect()), + Value::Object(o) => Value::Object(o.iter().map(|(k, x)| (k.clone(), redirect(key_location(k), x, safe_path))).collect()), + _ => v.clone(), + } +} + +/// `v`, with its strings sent to the temp location when `k` is a path key in any command +/// ([`is_path_key`]), and the strings under path keys in objects inside it too. pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value { - if is_path_key(k) && v.is_string() { json!(safe_location(k, safe_path)) } else { v.clone() } + redirect(key_location(k), v, safe_path) +} + +/// `v` as [`safe_value`] makes it for command `spec` ([`location_in`]): a key that names part of +/// the document in it keeps its strings. +pub fn safe_value_for_command(spec: &CommandSpec, k: &str, v: &Value, safe_path: &str) -> Value { + redirect(location_in(spec, k), v, safe_path) } /// A deterministic list of junk param objects for `doc` (the command's params doc): each -/// documented key set to each junk value, all keys junk at once, wrong top-level types. +/// documented key set to each junk value, all keys junk at once, wrong top-level types. A string +/// under a key that names a file or folder in any command ([`is_path_key`]) is a temp location. pub fn junk_params(doc: &str, safe_path: &str) -> Vec { - let keys = param_keys(doc); + junk_params_with(¶m_keys(doc), |k, v| safe_value(k, v, safe_path), key_location, safe_path) +} + +/// [`junk_params`] for command `spec`, with the keys of its schema and its file and folder keys too +/// ([`command_param_keys`]), and a string under each key that names a file or folder in it +/// ([`location_in`]) a temp location, as are the strings in an array of them and under the path +/// keys of objects in one (`paths: [{ids, path}]`), which it also sends. +pub fn junk_params_for_command(spec: &CommandSpec, safe_path: &str) -> Vec { + junk_params_with(&command_param_keys(spec), |k, v| safe_value_for_command(spec, k, v, safe_path), |k| location_in(spec, k), safe_path) +} + +/// The junk cases for `keys`: `safe(k, v)` is what is sent for junk value `v` under `k`, and +/// `kind(k)` what `k` names on disk, if anything, whose keys also get a list of locations and a +/// list of objects with a `path`. +fn junk_params_with(keys: &[String], safe: impl Fn(&str, &Value) -> Value, kind: impl Fn(&str) -> Option, safe_path: &str) -> Vec { let junk = junk_values(); let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})]; - for k in &keys { + for k in keys { for (i, v) in junk.iter().enumerate() { - let v = safe_value(k, v, safe_path); + let v = safe(k, v); // Sparse: one key at a time with every junk value, plus a few combos. let mut o = serde_json::Map::new(); o.insert(k.clone(), v.clone()); out.push(Value::Object(o)); if i % 5 == 0 { let mut all = serde_json::Map::new(); - for k2 in &keys { - let v2 = if is_path_key(k2) { json!(safe_location(k2, safe_path)) } else { v.clone() }; + for k2 in keys { + let v2 = kind(k2).map_or_else(|| v.clone(), |l| json!(location(l, safe_path))); all.insert(k2.clone(), v2); } out.push(Value::Object(all)); } } + if let Some(l) = kind(k) { + for v in [json!(["junk", "", 1]), json!([{"ids": [1], "path": "junk"}, {"path": null}])] { + out.push(json!({ k.as_str(): redirect(Some(l), &v, safe_path) })); + } + } } out } diff --git a/crates/text/src/context_alternates.rs b/crates/text/src/context_alternates.rs new file mode 100644 index 0000000000..034f65f1d5 --- /dev/null +++ b/crates/text/src/context_alternates.rs @@ -0,0 +1,147 @@ +//! The alternate glyphs of one character as it stands in its text: the glyph the shaper makes for +//! it in its word (a medial letter's medial form, not its isolated one), the alternates the font +//! gives that glyph, and for each the OpenType feature value that draws it on that character alone +//! (`salt=2`, `ss03`). Applying that feature to the character's range swaps the glyph in place +//! while the text stays real Unicode and the word is shaped as one piece, so the letters still +//! join. Adobe's Glyphs panel does the same for its Show › Alternates for Current Selection: +//! + +use kurbo::Point; +use vectorcraft_doc::{CharStyle, TextObject}; + +use crate::{FontDb, GlyphAlternate, OtFeatures, edit, layout}; + +/// The most characters on each side of the selected one that are shaped with it: the word it is in. +const MAX_WORD: usize = 256; + +/// The most alternates tried against the text. +const MAX_CANDIDATES: usize = 512; + +/// Does feature `tag` (as a style lists it, with its prefix and value) choose a glyph among a +/// glyph's alternates: `aalt`, `salt`, `swsh` or a stylistic set `ss01` to `ss20`? +pub fn is_alternate_feature(tag: &str) -> bool { + let name = OtFeatures::tag_name(tag); + matches!(name, "aalt" | "salt" | "swsh") || (name.len() == 4 && name.starts_with("ss") && OtFeatures::known_tag(name)) +} + +/// The tags to change a character's features by, as `featureChanges` takes them, so that it has +/// `tag` as its only feature that chooses an alternate: each such feature of `features` turned +/// off, and then `tag` (`salt=2`) when it is given. Empty when the character already has exactly +/// that. +pub fn alternate_changes(features: &[String], tag: Option<&str>) -> Vec { + let mut changes: Vec = vec![]; + for t in features.iter().filter(|t| is_alternate_feature(t) && !t.starts_with('-')) { + let off = format!("-{}", OtFeatures::tag_name(t)); + if tag.is_none_or(|n| OtFeatures::tag_name(n) != OtFeatures::tag_name(t)) && !changes.contains(&off) { + changes.push(off); + } + } + if let Some(tag) = tag { + let now = OtFeatures::from_tags(features.iter().map(String::as_str)); + let wanted = OtFeatures::from_tags([tag]); + let name = OtFeatures::tag_name(tag); + if now.feature_value(name) != wanted.feature_value(name) { + changes.push(tag.to_string()); + } + } + changes +} + +/// One glyph [`alternates_in_context`] lists. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContextAlternate { + pub gid: u32, + /// The feature that lists the glyph among the default glyph's alternates (`aalt` first). + pub feature: [u8; 4], + /// The feature tag that draws the glyph on the character in its word (`salt=2`), when one does. + pub apply: Option, +} + +/// What [`alternates_in_context`] finds. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContextAlternates { + /// The face that supplied the glyph ([`crate::PositionedGlyph::font_id`]). + pub font_id: u32, + /// The glyph the shaper makes for the character with none of its own alternate features: its + /// form in its position. + pub default: u32, + /// The glyph it is drawn with now. + pub current: u32, + /// The alternates of [`Self::default`] in the font's order (`aalt`, `salt`, `ss01`… as + /// [`crate::FontFace::alternates`]), without it unless `aalt` lists it. + pub alternates: Vec, +} + +/// The glyph of the layout `text` that draws the character at `byte`, as (face id, glyph id). +fn glyph_at(db: &FontDb, text: &TextObject, byte: usize) -> Option<(u32, u32, usize)> { + layout(db, text).glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len)).map(|g| (g.font_id, g.gid, g.len)) +} + +/// The alternate glyphs of the character at `byte` of `text` as it stands in its word, from the +/// glyph the shaper makes for it there, and the feature value that draws each on that character +/// alone. The character's own alternate features (`salt`, `ss01`…) are left out when the default +/// glyph is found; each alternate's feature is tried on the character in its word, and the one +/// that draws that glyph there is its `apply` (`salt` before the stylistic sets, `swsh` and +/// `aalt`; a glyph no feature of the font draws in this position has none). `None` when `byte` is +/// not a character of the text or the layout holds no glyph for it. Only the word is shaped (up to +/// 256 characters each side), so the cost does not grow with the text. +pub fn alternates_in_context(db: &FontDb, text: &TextObject, byte: usize) -> Option { + let plain = text.plain_text(); + let ch = plain.get(byte..)?.chars().next()?; + let end = byte + ch.len_utf8(); + let breaks = |c: char| c.is_whitespace() || matches!(c, '\u{2028}' | '\u{2029}'); + let start = plain[..byte].char_indices().rev().take(MAX_WORD).take_while(|(_, c)| !breaks(*c)).last().map_or(byte, |(i, _)| i); + let stop = plain[end..].char_indices().take(MAX_WORD).take_while(|(_, c)| !breaks(*c)).last().map_or(end, |(i, c)| end + i + c.len_utf8()); + let mut probe = TextObject::point(Point::ZERO, "", CharStyle::default()); + probe.runs = edit::slice_runs(&text.runs, start, stop); + probe.vertical = text.vertical; + probe.para = text.para.clone(); + let (a, b) = (byte - start, end - start); + let (_, current, _) = glyph_at(db, &probe, a)?; + let own = |probe: &TextObject, tag: Option<&str>| { + let mut p = probe.clone(); + edit::style_range(&mut p.runs, a, b, |st| { + st.glyph_alternate = None; + st.features.retain(|t| !is_alternate_feature(t)); + st.features.extend(tag.map(str::to_string)); + }); + glyph_at(db, &p, a) + }; + let (default_font, default, len) = own(&probe, None)?; + let face = db.face_by_id(default_font)?; + // A glyph that more than the character makes (a ligature) has no alternates of its own here. + let subs = if len == ch.len_utf8() { face.substitutions(default) } else { vec![] }; + let mut alternates: Vec = vec![]; + let mut tried: Vec<(String, u32)> = vec![]; + for sub in &subs { + let name = String::from_utf8_lossy(&sub.feature).to_string(); + let values = if sub.glyphs.is_empty() { 0 } else { u16::try_from(sub.glyphs.len()).unwrap_or(u16::MAX) }; + for value in 1..=values { + let tag = if value == 1 { name.clone() } else { format!("{name}={value}") }; + if !is_alternate_feature(&tag) || !OtFeatures::known_tag(&tag) || tried.len() >= MAX_CANDIDATES || tried.iter().any(|(t, _)| *t == tag) { + continue; + } + // A single substitution gives its glyph at any value: only the plain tag is tried. + if sub.kind == crate::SubstitutionKind::Single && value > 1 { + continue; + } + if let Some((font, gid, _)) = own(&probe, Some(&tag)) + && font == default_font + { + tried.push((tag, gid)); + } + } + } + for GlyphAlternate { gid, feature } in crate::fontdb::listed(&subs, default) { + let rank = |tag: &str| match OtFeatures::tag_name(tag) { + "salt" => 0, + "swsh" => 2, + "aalt" => 3, + _ => 1, + }; + let mut drawing: Vec<&(String, u32)> = tried.iter().filter(|(_, g)| *g == gid).collect(); + drawing.sort_by_key(|(t, _)| rank(t)); + alternates.push(ContextAlternate { gid, feature, apply: drawing.first().map(|(t, _)| t.clone()) }); + } + Some(ContextAlternates { font_id: default_font, default, current, alternates }) +} diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index ddf9e5a03c..9f2b555e23 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -43,20 +43,32 @@ pub struct OtFeatures { pub swash: bool, /// The features of [`EXTRA_FEATURES`] turned on, one bit each (off by default): stylistic /// sets, alternates, titling, proportional and lining figures, superscript, subscript, - /// numerators and denominators. + /// numerators and denominators, `aalt`. pub extra: u32, + /// The value of each feature of [`EXTRA_FEATURES`] that is on, as a tag `salt=2` gives it: for + /// a feature with alternates, which of them the font draws (the HarfBuzz feature value, 1 for + /// the first). 0 stands for 1, a plain `salt`. Only read for a feature whose bit of + /// [`Self::extra`] is on; use [`Self::set_extra`] to change a bit and its value together. + pub extra_values: [u16; EXTRA_FEATURES.len()], + /// The value `swsh=N` gives swashes ([`Self::swash`]), as [`Self::extra_values`] for `salt`. + pub swash_value: u16, } +/// The largest value a `tag=N` feature takes: a font's glyph ids are 16 bits, so is an alternate's +/// position in a set. +pub const MAX_FEATURE_VALUE: u16 = u16::MAX; + /// The most entries of a style's feature list read: every segment of its text reads the list again. pub const MAX_STYLE_FEATURES: usize = 256; /// Features beyond the named fields, off unless asked for, each bit `i` of [`OtFeatures::extra`]: /// stylistic sets `ss01`–`ss20`, stylistic alternates `salt`, titling `titl`, proportional `pnum` -/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr` and -/// denominators `dnom`. -pub const EXTRA_FEATURES: [&str; 28] = [ +/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr`, +/// denominators `dnom` and access all alternates `aalt`. +/// +pub const EXTRA_FEATURES: [&str; 29] = [ "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18", - "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", + "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", "aalt", ]; impl Default for OtFeatures { @@ -73,12 +85,53 @@ impl Default for OtFeatures { ordinals: false, swash: false, extra: 0, + extra_values: [0; EXTRA_FEATURES.len()], + swash_value: 0, } } } -fn f(tag: &[u8; 4], on: bool) -> Feature { - Feature::new(Tag::new(tag), on as u32, ..) +/// A feature of `value` over the whole text: 1 and 0 turn it on and off, more picks the font's +/// alternate of that number. +fn f(tag: &[u8; 4], value: impl Into) -> Feature { + Feature::new(Tag::new(tag), value.into(), ..) +} + +/// What a feature tag of a style's list says about one feature ([`parse_tag`]). +struct ParsedTag<'a> { + /// The tag without a `-` or `+` prefix or a `=N` value. + name: &'a str, + /// On (`tag`, `+tag`, `tag=N` for `N` from 1) or off (`-tag`, `tag=0`). + on: bool, + /// The value `tag=N` gives; 1 for a plain tag. + value: u16, +} + +/// Can feature `name` take a value (`salt=2`)? The features of [`EXTRA_FEATURES`] and `swsh` can: +/// each substitutes glyphs, and the value says which alternate. +fn takes_value(name: &str) -> bool { + name == "swsh" || EXTRA_FEATURES.contains(&name) +} + +/// Read a feature tag: `tag`, `+tag`, `-tag` or, for a feature that takes a value, `tag=N` (`N` +/// from 0 to [`MAX_FEATURE_VALUE`] in decimal digits; `-tag=N` is refused). `None` for a tag this +/// set does not understand. +fn parse_tag(tag: &str) -> Option> { + let (off, rest) = match tag.strip_prefix('-') { + Some(r) => (true, r), + None => (false, tag.strip_prefix('+').unwrap_or(tag)), + }; + let (name, value) = match rest.split_once('=') { + None => (rest, None), + Some((name, digits)) => { + if off || digits.is_empty() || digits.len() > 5 || !digits.bytes().all(|b| b.is_ascii_digit()) || !takes_value(name) { + return None; + } + (name, Some(u16::try_from(digits.parse::().ok()?).ok()?)) + } + }; + let known = matches!(name, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&name); + known.then_some(ParsedTag { name, on: !off && value != Some(0), value: value.unwrap_or(1) }) } impl OtFeatures { @@ -104,6 +157,8 @@ impl OtFeatures { ordinals: true, swash: true, extra: u32::MAX >> (32 - EXTRA_FEATURES.len()), + extra_values: [0; EXTRA_FEATURES.len()], + swash_value: 0, }; change(&mut off); change(&mut on); @@ -137,28 +192,68 @@ impl OtFeatures { (self.swash, d.swash, "swsh"), ] { if on != def { - v.push(if on { tag.to_string() } else { format!("-{tag}") }); + v.push(match (on, tag, self.swash_value) { + (true, "swsh", n @ 2..) => format!("{tag}={n}"), + (true, ..) => tag.to_string(), + (false, ..) => format!("-{tag}"), + }); } } - v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(_, t)| t.to_string())); + v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(i, t)| match self.extra_values.get(i) { + None | Some(0 | 1) => t.to_string(), + Some(n) => format!("{t}={n}"), + })); v } - /// Is `tag` (optionally prefixed with `-` or `+`) one this set understands? + /// Is `tag` one this set understands? A feature tag, optionally prefixed with `-` or `+`, or + /// `tag=N` (`N` from 0 to [`MAX_FEATURE_VALUE`], no prefix but `+`) for a feature of + /// [`EXTRA_FEATURES`] or `swsh`, which picks the font's alternate number `N` (`salt=2`; 0 is + /// off, 1 is a plain tag). pub fn known_tag(tag: &str) -> bool { + parse_tag(tag).is_some() + } + + /// The feature a tag of a style's list names, without its `-` or `+` prefix or `=N` value + /// (`salt` for `-salt`, `+salt` and `salt=2`). + pub fn tag_name(tag: &str) -> &str { let t = tag.strip_prefix(['-', '+']).unwrap_or(tag); - matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&t) + t.split_once('=').map_or(t, |(name, _)| name) + } + + /// The value feature `tag` of [`EXTRA_FEATURES`] or `swsh` is on with: `Some(1)` for a plain + /// tag, `Some(N)` for `tag=N`, `None` when it is off or is another tag. + pub fn feature_value(&self, tag: &str) -> Option { + if tag == "swsh" { + return self.swash.then_some(self.swash_value.max(1)); + } + let i = EXTRA_FEATURES.iter().position(|e| *e == tag)?; + (self.extra & (1 << i) != 0).then(|| self.extra_values.get(i).copied().unwrap_or(0).max(1)) + } + + /// Turn the features of [`EXTRA_FEATURES`] in `mask` (bit `i` for the feature `i`) on, with + /// value 1 (a plain tag), or off. + pub fn set_extra(&mut self, mask: u32, on: bool) { + for (i, value) in self.extra_values.iter_mut().enumerate() { + if mask & (1 << i) != 0 { + *value = 0; + } + } + if on { + self.extra |= mask; + } else { + self.extra &= !mask; + } } /// This set with `tags` applied on top. pub fn with_tags<'a>(mut self, tags: impl IntoIterator) -> Self { let o = &mut self; for t in tags { - let (on, t) = match t.strip_prefix('-') { - Some(r) => (false, r), - None => (true, t.strip_prefix('+').unwrap_or(t)), - }; - match t { + let Some(ParsedTag { name, on, value }) = parse_tag(t) else { continue }; + // 1 is a plain tag: only a value from 2 is kept. + let kept = if on && value > 1 { value } else { 0 }; + match name { "liga" => o.ligatures = on, "calt" => o.contextual = on, "dlig" => o.discretionary_ligatures = on, @@ -167,13 +262,15 @@ impl OtFeatures { "onum" => o.oldstyle_figures = on, "tnum" => o.tabular_figures = on, "ordn" => o.ordinals = on, - "swsh" => o.swash = on, + "swsh" => { + o.swash = on; + o.swash_value = kept; + } _ => { - if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == t) { - if on { - o.extra |= 1 << i; - } else { - o.extra &= !(1 << i); + if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == name) { + o.set_extra(1 << i, on); + if let Some(slot) = o.extra_values.get_mut(i) { + *slot = kept; } } } @@ -214,12 +311,12 @@ impl OtFeatures { (s.swash, b"swsh"), ] { if on { - v.push(f(tag, true)); + v.push(f(tag, if tag == b"swsh" { u32::from(s.swash_value.max(1)) } else { 1 })); } } for (i, tag) in EXTRA_FEATURES.iter().enumerate() { if let (true, Ok(t)) = (s.extra & (1 << i) != 0, <[u8; 4]>::try_from(tag.as_bytes())) { - v.push(f(&t, true)); + v.push(f(&t, u32::from(s.extra_values.get(i).copied().unwrap_or(0).max(1)))); } } if self.vertical { @@ -354,6 +451,69 @@ mod tests { assert_eq!(OtFeatures::from_tags(start.iter().copied()).with_tags(tags.iter().map(String::as_str)), want, "{start:?}"); } } + + /// `tag=N` gives a feature a value: parsed, validated, kept in the style's list as written + /// (1 is a plain tag, 0 is off), replaced by the next value or by `-tag`, and given to the + /// shaper as that value. + #[test] + fn feature_values_are_parsed_validated_and_shaped() { + for t in ["salt=2", "+salt=2", "ss03=12", "aalt=65535", "swsh=3", "salt=0", "salt=01", "titl=2"] { + assert!(OtFeatures::known_tag(t), "{t}"); + } + for t in [ + "salt=", + "salt=-1", + "salt=65536", + "salt=1.5", + "salt=x", + "-salt=2", + "liga=2", + "dlig=1", + "zzzz=2", + "salt==2", + "=2", + "salt=2=3", + "salt=123456", + "salt= 2", + ] { + assert!(!OtFeatures::known_tag(t), "{t}"); + } + assert_eq!(OtFeatures::tag_name("salt=2"), "salt"); + assert_eq!((OtFeatures::tag_name("-ss01"), OtFeatures::tag_name("+salt=3"), OtFeatures::tag_name("liga")), ("ss01", "salt", "liga")); + let o = OtFeatures::from_tags(["salt=2", "ss01", "-liga", "swsh=4"]); + assert_eq!(o.to_tags(), ["-liga", "swsh=4", "ss01", "salt=2"]); + assert_eq!( + (o.feature_value("salt"), o.feature_value("ss01"), o.feature_value("swsh"), o.feature_value("ss02")), + (Some(2), Some(1), Some(4), None) + ); + // The next value replaces the last, `salt=1` is a plain `salt`, `salt=0` and `-salt` are off. + assert_eq!(OtFeatures::from_tags(["salt=2", "salt=3"]).to_tags(), ["salt=3"]); + assert_eq!(OtFeatures::from_tags(["salt=3", "salt"]).to_tags(), ["salt"]); + assert_eq!(OtFeatures::from_tags(["salt=1"]).to_tags(), ["salt"]); + assert_eq!(OtFeatures::from_tags(["salt=2", "-salt"]).to_tags(), Vec::::new()); + assert_eq!(OtFeatures::from_tags(["salt=2", "salt=0"]).to_tags(), Vec::::new()); + assert_eq!(OtFeatures::from_tags(["salt=2", "-salt", "salt"]).to_tags(), ["salt"], "a value does not outlive being turned off"); + // A tag with a value this set does not know is ignored. + assert_eq!(OtFeatures::from_tags(["salt=x", "liga=2"]), OtFeatures::default()); + // The panel's own switches turn a feature on at value 1. + let mut f = OtFeatures::from_tags(["salt=3"]); + f.set_extra(1 << 20, false); + f.set_extra(1 << 20, true); + assert_eq!(f.to_tags(), ["salt"]); + let values = |tags: &[&str]| -> Vec<(String, u32)> { + let st = CharStyle { features: tags.iter().map(|t| t.to_string()).collect(), ..CharStyle::default() }; + OtFeatures::default() + .resolve(&st) + .iter() + .filter(|f| f.value > 0 && f.tag != harfrust::Tag::new(b"kern")) + .map(|f| (f.tag.to_string(), f.value)) + .collect() + }; + assert_eq!(values(&["salt=2"]), [("salt".to_string(), 2)]); + assert_eq!(values(&["salt"]), [("salt".to_string(), 1)]); + assert_eq!(values(&["swsh=3", "ss01"]), [("swsh".to_string(), 3), ("ss01".to_string(), 1)]); + assert!(values(&["-salt", "salt=0"]).is_empty()); + } use crate::{FontDb, layout}; use vectorcraft_doc::TextObject; use vectorcraft_geom::Point; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 12d27bd119..9e18b2a86c 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -25,6 +25,7 @@ mod db; mod dirs; mod face; mod glyph_pen; +mod gsub; mod lookup; mod names; mod scan; @@ -33,6 +34,7 @@ mod traits; mod wanted; pub use alternates::GlyphAlternate; +pub(crate) use alternates::listed; pub use build::style_weight; #[cfg(test)] use cache::GlyphCache; @@ -52,6 +54,7 @@ pub(crate) use face::MAX_LIVE_INSTANCES; pub(crate) use face::PathAlignHeights; pub use face::{FALLBACK_FAMILY, FontFace, IcfMargins}; pub(crate) use glyph_pen::FlipPen; +pub use gsub::{GlyphOrigin, JoiningPosition, Substitution, SubstitutionKind}; #[cfg(not(target_arch = "wasm32"))] use names::SysFallback; #[cfg(test)] diff --git a/crates/text/src/fontdb/alternates.rs b/crates/text/src/fontdb/alternates.rs index 2a582bbe71..4a374755ef 100644 --- a/crates/text/src/fontdb/alternates.rs +++ b/crates/text/src/fontdb/alternates.rs @@ -1,16 +1,16 @@ //! The alternate glyphs a face's `GSUB` table gives one character: the Glyphs panel's Show › //! Alternates for Current Selection. The application finds the character's default glyph in the -//! `cmap` and looks it up in the coverage of each lookup of the features that substitute one glyph -//! for another, as the OpenType registry describes `aalt`. +//! `cmap` and lists what the features that substitute one glyph for another give it, as the +//! OpenType registry describes `aalt` ([`FontFace::substitutions`] reads them). //! //! use skrifa::MetadataProvider; use skrifa::raw::TableProvider; -use skrifa::raw::tables::gsub::{AlternateSubstFormat1, SingleSubst, SubstitutionSubtables}; use vectorcraft_doc::GlyphChoice; use super::FontFace; +use super::gsub::Substitution; /// One glyph [`FontFace::alternates`] lists: its id and the feature it was found in (`aalt`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -19,108 +19,83 @@ pub struct GlyphAlternate { pub feature: [u8; 4], } -/// Caps on what a damaged or very large `GSUB` table can make [`FontFace::alternates`] read: -/// feature records, lookups per feature and in all, subtables per lookup and in all, glyphs per -/// alternate set and glyphs listed in all. -const MAX_FEATURES: usize = 4096; -const MAX_FEATURE_LOOKUPS: usize = 256; -const MAX_LOOKUPS: usize = 4096; -const MAX_SUBTABLES: usize = 256; -const MAX_SUBTABLE_READS: usize = 16384; -const MAX_SET: usize = 512; +/// The most glyphs [`FontFace::alternates`] lists. const MAX_ALTERNATES: usize = 1024; -/// The other discretionary features that substitute one glyph for another (historical forms, -/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in -/// tag order. -const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"]; - -/// Where the glyphs of feature `tag` come in the list: `aalt`, `salt`, `ss01` to `ss20`, `swsh`, -/// `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not listed. -fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> { - let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::().ok()); - match &tag { - b"aalt" => Some((0, 0)), - b"salt" => Some((1, 0)), - [b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)), - b"swsh" => Some((3, 0)), - [b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)), - _ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)), +/// The alternates of glyph `default` that its substitutions `subs` ([`FontFace::substitutions`]) +/// list, in their order, each glyph once with the first feature that gives it: the default glyph +/// itself only when `aalt` gives it. +pub(crate) fn listed(subs: &[Substitution], default: u32) -> Vec { + let mut out: Vec = vec![]; + for substitution in subs { + for &gid in &substitution.glyphs { + let wanted = gid != default || substitution.feature == *b"aalt"; + if wanted && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) { + out.push(GlyphAlternate { gid, feature: substitution.feature }); + } + } } + out } impl FontFace { /// The face's alternate glyphs for `c`, from its `GSUB` table: the glyphs single substitutions /// (lookup type 1) and alternate substitutions (type 3, the whole set in the font's order), in - /// extension lookups (type 7) too, put in place of `c`'s default glyph. `aalt` comes first, - /// then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and - /// `titl`; within a feature its lookups go in lookup order. A glyph found again is left out. - /// The default glyph is listed only when `aalt` lists it. Ligatures and contextual - /// substitutions are not read. Empty when the face does not map `c`, has no `GSUB` table, or - /// the table cannot be read; glyph ids past the face's glyph count are left out. + /// extension lookups (type 7) too, put in place of `c`'s default glyph + /// ([`FontFace::substitutions`]). `aalt` comes first, then `salt`, `ss01` to `ss20`, `swsh`, + /// `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and `titl`; within a feature its lookups go + /// in lookup order. A glyph found again is left out. The default glyph is listed only when + /// `aalt` lists it. Ligatures and contextual substitutions are not read. Empty when the face + /// does not map `c`, has no `GSUB` table, or the table cannot be read; glyph ids past the face's + /// glyph count are left out. pub fn alternates(&self, c: char) -> Vec { - let mut out: Vec = vec![]; - let Some(f) = self.skrifa() else { return out }; - let Some(default) = f.charmap().map(c) else { return out }; - let Ok(gsub) = f.gsub() else { return out }; - let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return out }; - let glyphs = f.maxp().map_or(0, |m| u32::from(m.num_glyphs())); - // The lookups of the listed features, by rank and lookup index. - // The listed feature records are read in rank order, so the cap on lookups in all leaves - // out the lowest ranked ones. - let records = features.feature_records(); - let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records - .iter() - .take(MAX_FEATURES) - .enumerate() - .filter_map(|(i, r)| { - let tag = r.feature_tag().to_be_bytes(); - feature_rank(tag).map(|rank| (rank, i, tag)) + let Some(default) = self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()) else { return vec![] }; + listed(&self.substitutions(default), default) + } + + /// The glyphs no character maps (its `cmap` gives no character for them), by glyph id, without + /// `.notdef`: the positional forms, alternates and ligatures the `GSUB` table reaches and any + /// other glyph of the font. Read once for the face. + pub fn unencoded_glyphs(&self) -> std::sync::Arc<[u32]> { + self.unencoded + .get_or_init(|| { + let Some(font) = self.skrifa() else { return std::sync::Arc::from([]) }; + let mapped: std::collections::HashSet = font.charmap().mappings().map(|(_, g)| g.to_u32()).collect(); + let count = font.maxp().map_or(0, |m| u32::from(m.num_glyphs())); + (1..count).filter(|g| !mapped.contains(g)).collect() }) - .collect(); - ranked.sort_unstable_by_key(|r| (r.0, r.1)); - let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![]; - for (rank, i, tag) in ranked { - let room = MAX_LOOKUPS.saturating_sub(wanted.len()); - if room == 0 { - break; - } - let Some(record) = records.get(i) else { continue }; - let Ok(feature) = record.feature(features.offset_data()) else { continue }; - wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag))); - } - wanted.sort_unstable_by_key(|w| (w.0, w.1)); - wanted.dedup_by_key(|w| (w.0, w.1)); - let mut push = |gid: u32, feature: [u8; 4]| { - let listed = gid != default.to_u32() || feature == *b"aalt"; - if listed && gid < glyphs && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) { - out.push(GlyphAlternate { gid, feature }); - } - }; - let all = lookups.lookups(); - let mut reads = MAX_SUBTABLE_READS; - for (_, index, tag) in wanted { - if reads == 0 { - break; - } - let Ok(subtables) = all.get(usize::from(index)).and_then(|l| l.subtables()) else { continue }; - let n = MAX_SUBTABLES.min(reads); - // A glyph takes the first subtable of a lookup whose coverage holds it. - match subtables { - SubstitutionSubtables::Single(s) => { - if let Some(gid) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| single(&t, default.to_u32())) { - push(gid, tag); - } + .clone() + } + + /// Is glyph `gid` one no character maps and no feature reaches, whose name does not give it + /// to another letter than `c` (`a.salt` for `a` in a font with no `salt`; `A.salt` is not + /// `a`'s)? Such a glyph is not among [`Self::alternates`], yet it can be drawn for `c` as a + /// glyph choice ([`Self::choice`]). + pub fn is_unlisted_alternate(&self, c: char, gid: u32) -> bool { + self.unencoded_glyphs().binary_search(&gid).is_ok() + && self.feature_reaching(gid).is_none() + && self.name_owner(gid).is_none_or(|owner| owner == c) + } + + /// The character a glyph's name says it belongs to: the one whose glyph carries the name up + /// to its first `.` (`A` for `A.salt`, `s` for `s.ss1`; the lowest character when several map + /// that glyph). `None` when the glyph has no name, or no character maps a glyph of that name. + fn name_owner(&self, gid: u32) -> Option { + let base = self.glyph_name(gid)?; + let base = base.split('.').next().unwrap_or_default(); + let names = self.named_chars.get_or_init(|| { + let Some(font) = self.skrifa() else { return Default::default() }; + let mut by_char: Vec<(char, u32)> = font.charmap().mappings().filter_map(|(c, g)| Some((char::from_u32(c)?, g.to_u32()))).collect(); + by_char.sort_unstable(); + let mut out = std::collections::HashMap::new(); + for (c, g) in by_char { + if let Some(name) = self.glyph_name(g) { + out.entry(name).or_insert(c); } - SubstitutionSubtables::Alternate(s) => { - if let Some(set) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| alternate_set(&t, default.to_u32())) { - set.into_iter().for_each(|gid| push(gid, tag)); - } - } - _ => {} } - } - out + out + }); + names.get(base).copied() } /// How many glyphs the face has (its `maxp` table; 0 when it can't be read). @@ -141,25 +116,3 @@ impl FontFace { GlyphChoice { ch: c, gid, font: self.postscript_name(), version: self.version.clone() } } } - -/// The glyph single substitution `t` puts in place of `gid`, when its coverage holds `gid`. -fn single(t: &SingleSubst<'_>, gid: u32) -> Option { - match t { - SingleSubst::Format1(t) => { - t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; - // The delta is added modulo 65536. - Some((gid as i32 + i32::from(t.delta_glyph_id())).rem_euclid(0x1_0000) as u32) - } - SingleSubst::Format2(t) => { - let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; - t.substitute_glyph_ids().get(usize::from(i)).map(|g| u32::from(g.get().to_u16())) - } - } -} - -/// The alternate set of alternate substitution `t` for `gid`, when its coverage holds `gid`. -fn alternate_set(t: &AlternateSubstFormat1<'_>, gid: u32) -> Option> { - let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; - let set = t.alternate_sets().get(usize::from(i)).ok()?; - Some(set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect()) -} diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs index 4946bbd844..afdee14a55 100644 --- a/crates/text/src/fontdb/build.rs +++ b/crates/text/src/fontdb/build.rs @@ -79,6 +79,9 @@ pub(super) fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Opt colour_bitmaps, colour_layers, svg_glyphs, + gsub: std::sync::OnceLock::new(), + unencoded: std::sync::OnceLock::new(), + named_chars: std::sync::OnceLock::new(), }) } diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index fa59ff8eec..ff9ad8d58f 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -201,6 +201,13 @@ pub struct FontFace { pub(super) colour_layers: bool, /// Has an `SVG ` table: OpenType SVG glyphs ([`FontDb::glyph_colour`]). pub(super) svg_glyphs: bool, + /// What the `GSUB` table substitutes ([`Self::substitutions`]), read the first time it is asked + /// for. + pub(super) gsub: std::sync::OnceLock, + /// [`Self::unencoded_glyphs`], read once. + pub(super) unencoded: std::sync::OnceLock>, + /// [`Self::name_owner`]'s glyph names to characters, read once. + pub(super) named_chars: std::sync::OnceLock>, } /// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance diff --git a/crates/text/src/fontdb/gsub.rs b/crates/text/src/fontdb/gsub.rs new file mode 100644 index 0000000000..888c89ab09 --- /dev/null +++ b/crates/text/src/fontdb/gsub.rs @@ -0,0 +1,359 @@ +//! What a face's `GSUB` table substitutes for a glyph: the alternates of one glyph (the Glyphs +//! panel's Show › Alternates for Current Selection), the feature that gives a glyph no character +//! maps to (Show › Entire Font) and glyph names. Read once for a face, the first time one is asked +//! for, and kept with it ([`FontFace`]). +//! +//! A feature of alternates (an alternate substitution, lookup type 3; `salt`, `aalt`, `swsh`) +//! takes the alternate its value says: with the feature at value `N`, a glyph in its coverage is +//! replaced by the `N`th glyph of its set (the first at value 1, none at 0 or past the set), which +//! is how the shaper reads it. A feature of single substitutions (type 1; `ss01`, `smcp`) replaces +//! the glyph at any value from 1. +//! +//! + +use std::collections::{HashMap, HashSet}; + +use skrifa::MetadataProvider; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::gsub::{AlternateSubstFormat1, LigatureSubstFormat1, MultipleSubstFormat1, SingleSubst, SubstitutionSubtables}; + +use super::FontFace; + +/// Caps on what a damaged or very large `GSUB` table can make a face read: feature records, +/// lookups per feature and in all, subtables per lookup and in all, glyphs per alternate set, and +/// glyphs and substitutions listed in all. +pub(super) const MAX_FEATURES: usize = 4096; +const MAX_FEATURE_LOOKUPS: usize = 256; +const MAX_LOOKUPS: usize = 4096; +const MAX_SUBTABLES: usize = 256; +const MAX_SUBTABLE_READS: usize = 16384; +pub(super) const MAX_SET: usize = 512; +const MAX_ENTRIES: usize = 1 << 18; + +/// The other discretionary features that substitute one glyph for another (historical forms, +/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in +/// tag order. +const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"]; + +/// Where the substitutions of feature `tag` come in a glyph's list: `aalt`, `salt`, `ss01` to +/// `ss20`, `swsh`, `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not +/// listed. +fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> { + let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::().ok()); + match &tag { + b"aalt" => Some((0, 0)), + b"salt" => Some((1, 0)), + [b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)), + b"swsh" => Some((3, 0)), + [b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)), + _ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)), + } +} + +/// The rank of the features that give no listed alternates but reach glyphs ([`FontFace::feature_reaching`]). +const UNLISTED: (u8, u8) = (9, 0); + +/// How a feature gives its glyphs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SubstitutionKind { + /// One glyph, whatever the feature's value is from 1 (a single substitution, type 1). + Single, + /// A set of glyphs, the one at the feature's value (an alternate substitution, type 3). + Alternate, +} + +/// What one feature substitutes for a glyph ([`FontFace::substitutions`]). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Substitution { + /// The feature's tag (`salt`). + pub feature: [u8; 4], + pub kind: SubstitutionKind, + /// The glyphs, in the font's order: the one glyph of a single substitution, or the alternate + /// set (the feature at value `N` gives the `N`th). + pub glyphs: Vec, +} + +impl Substitution { + /// The value the feature takes to give `glyph` (`salt=2` for 2), `None` when it never gives it. + pub fn value_for(&self, glyph: u32) -> Option { + match self.kind { + SubstitutionKind::Single => (self.glyphs.first() == Some(&glyph)).then_some(1), + SubstitutionKind::Alternate => self.glyphs.iter().position(|g| *g == glyph).and_then(|i| u16::try_from(i + 1).ok()), + } + } +} + +type Tag = [u8; 4]; + +/// A face's substitutions, by the glyph they replace and by the glyph they give. +#[derive(Default)] +pub(super) struct GsubIndex { + /// The listed features' ([`feature_rank`]) substitutions of each glyph, by feature rank and + /// lookup. + by_input: HashMap>, + /// The first feature, listed ones first, that gives each glyph: by a single, multiple, + /// alternate or ligature substitution, and the glyph it replaces (not known for a ligature). + reached: HashMap)>, +} + +/// The glyph single substitution `t` puts in place of each glyph its coverage holds. +fn single_pairs(t: &SingleSubst<'_>, room: usize) -> Vec<(u32, u32)> { + match t { + SingleSubst::Format1(t) => { + let Ok(coverage) = t.coverage() else { return vec![] }; + // The delta is added modulo 65536. + let delta = i32::from(t.delta_glyph_id()); + coverage.iter().take(room).map(|g| (g.to_u32(), (g.to_u32() as i32 + delta).rem_euclid(0x1_0000) as u32)).collect() + } + SingleSubst::Format2(t) => { + let Ok(coverage) = t.coverage() else { return vec![] }; + let out = t.substitute_glyph_ids(); + coverage.iter().zip(out).take(room).map(|(g, to)| (g.to_u32(), u32::from(to.get().to_u16()))).collect() + } + } +} + +/// The alternate set of each glyph alternate substitution `t`'s coverage holds. +fn alternate_sets(t: &AlternateSubstFormat1<'_>, room: usize) -> Vec<(u32, Vec)> { + let Ok(coverage) = t.coverage() else { return vec![] }; + let sets = t.alternate_sets(); + coverage + .iter() + .enumerate() + .take(room) + .filter_map(|(i, g)| { + let set = sets.get(i).ok()?; + Some((g.to_u32(), set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect())) + }) + .collect() +} + +/// The glyphs multiple substitution `t` puts in place of its covered glyphs, and the ligature +/// glyphs of ligature substitution `l`, as (glyph, glyphs it becomes). +fn multiple_pairs(t: &MultipleSubstFormat1<'_>, room: usize) -> Vec<(u32, Vec)> { + let Ok(coverage) = t.coverage() else { return vec![] }; + let sequences = t.sequences(); + coverage + .iter() + .enumerate() + .take(room) + .filter_map(|(i, g)| { + let sequence = sequences.get(i).ok()?; + Some((g.to_u32(), sequence.substitute_glyph_ids().iter().map(|g| u32::from(g.get().to_u16())).collect())) + }) + .collect() +} + +fn ligature_glyphs(t: &LigatureSubstFormat1<'_>, room: usize) -> Vec { + let Ok(coverage) = t.coverage() else { return vec![] }; + let sets = t.ligature_sets(); + let mut out = vec![]; + for (i, _) in coverage.iter().enumerate().take(room) { + let Ok(set) = sets.get(i) else { continue }; + out.extend(set.ligatures().iter().flatten().take(room).map(|l| u32::from(l.ligature_glyph().to_u16()))); + if out.len() >= room { + break; + } + } + out +} + +impl GsubIndex { + /// Read `face`'s `GSUB` table. Empty when the face has none or it cannot be read; glyph ids past + /// the face's glyph count are left out; the caps above bound what a damaged table makes it read. + fn read(face: &FontFace) -> Self { + let mut index = Self::default(); + let Some(font) = face.skrifa() else { return index }; + let Ok(gsub) = font.gsub() else { return index }; + let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return index }; + let glyphs = font.maxp().map_or(0, |m| u32::from(m.num_glyphs())); + // The lookups of the features, by rank and lookup index. The feature records are read in + // rank order, so the cap on lookups in all leaves out the lowest ranked ones. + let records = features.feature_records(); + let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records + .iter() + .take(MAX_FEATURES) + .enumerate() + .map(|(i, r)| { + let tag = r.feature_tag().to_be_bytes(); + (feature_rank(tag).unwrap_or(UNLISTED), i, tag) + }) + .collect(); + ranked.sort_unstable_by_key(|r| (r.0, r.1)); + let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![]; + for (rank, i, tag) in ranked { + let room = MAX_LOOKUPS.saturating_sub(wanted.len()); + if room == 0 { + break; + } + let Some(record) = records.get(i) else { continue }; + let Ok(feature) = record.feature(features.offset_data()) else { continue }; + wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag))); + } + wanted.sort_by_key(|w| (w.0, w.1)); + wanted.dedup_by_key(|w| (w.0, w.1)); + let all = lookups.lookups(); + let mut reads = MAX_SUBTABLE_READS; + let mut entries = 0_usize; + for (rank, lookup, tag) in wanted { + if reads == 0 || entries >= MAX_ENTRIES { + break; + } + let Ok(subtables) = all.get(usize::from(lookup)).and_then(|l| l.subtables()) else { continue }; + let n = MAX_SUBTABLES.min(reads); + let listed = rank != UNLISTED; + // A glyph takes the first subtable of a lookup whose coverage holds it. + let mut seen: HashSet = HashSet::new(); + let mut note = |index: &mut Self, input: u32, kind: SubstitutionKind, out: Vec| { + if !seen.insert(input) { + return; + } + let out: Vec = out.into_iter().filter(|g| *g < glyphs).collect(); + for g in &out { + if *g != input && index.reached.len() < MAX_ENTRIES { + index.reached.entry(*g).or_insert((tag, Some(input))); + } + } + if listed && !out.is_empty() && entries < MAX_ENTRIES { + entries += 1; + index.by_input.entry(input).or_default().push(Substitution { feature: tag, kind, glyphs: out }); + } + }; + match subtables { + SubstitutionSubtables::Single(s) => { + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for (input, to) in single_pairs(&t, MAX_ENTRIES) { + note(&mut index, input, SubstitutionKind::Single, vec![to]); + } + } + } + SubstitutionSubtables::Alternate(s) => { + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for (input, set) in alternate_sets(&t, MAX_ENTRIES) { + note(&mut index, input, SubstitutionKind::Alternate, set); + } + } + } + SubstitutionSubtables::Multiple(s) => { + let mut covered: HashSet = HashSet::new(); + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for (input, to) in multiple_pairs(&t, MAX_ENTRIES) { + // Not listed as alternates, only reached. + if covered.insert(input) { + for g in to.into_iter().filter(|g| *g < glyphs && *g != input) { + if index.reached.len() < MAX_ENTRIES { + index.reached.entry(g).or_insert((tag, Some(input))); + } + } + } + } + } + } + SubstitutionSubtables::Ligature(s) => { + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for g in ligature_glyphs(&t, MAX_ENTRIES).into_iter().filter(|g| *g < glyphs) { + if index.reached.len() < MAX_ENTRIES { + index.reached.entry(g).or_insert((tag, None)); + } + } + } + } + _ => {} + } + } + index + } +} + +/// The most substitutions [`FontFace::glyph_origin`] follows back. +const MAX_ORIGIN_STEPS: usize = 8; + +/// The character a glyph comes from ([`FontFace::glyph_origin`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GlyphOrigin { + pub ch: char, + /// The features from the glyph back to the character's glyph, the glyph's own first. + pub features: Vec<[u8; 4]>, +} + +impl GlyphOrigin { + /// The place in a word that the glyph's features give it: the first of `init`, `medi`, `fina` + /// and `isol` among them, `None` when none is (a letter that does not join). + pub fn position(&self) -> Option { + self.features.iter().find_map(|f| match f { + b"init" => Some(crate::JoiningPosition::Initial), + b"medi" => Some(crate::JoiningPosition::Medial), + b"fina" => Some(crate::JoiningPosition::Final), + b"isol" => Some(crate::JoiningPosition::Isolated), + _ => None, + }) + } +} + +/// Where a letter stands in a word, as the `init`, `medi`, `fina` and `isol` features say. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JoiningPosition { + Initial, + Medial, + Final, + Isolated, +} + +impl FontFace { + fn gsub_index(&self) -> &GsubIndex { + self.gsub.get_or_init(|| GsubIndex::read(self)) + } + + /// What the features `aalt`, `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, + /// `jalt`, `nalt`, `ornm` and `titl` of the face's `GSUB` table substitute for glyph `gid`, in + /// that order of features and within a feature in lookup order: the glyph of each single + /// substitution (lookup type 1) and the whole set of each alternate substitution (type 3), in + /// extension lookups (type 7) too, as the shaper reads them. A feature gives the glyph its + /// first substitution (the first subtable of the lookup whose coverage holds it). Ligatures and + /// contextual substitutions are not read, nor is the choice of a variable font's instance of + /// `FeatureVariations`. Empty when the face has no `GSUB` table or it cannot be read; glyph ids + /// past the face's glyph count are left out. + pub fn substitutions(&self, gid: u32) -> Vec { + self.gsub_index().by_input.get(&gid).cloned().unwrap_or_default() + } + + /// The feature of the `GSUB` table that gives glyph `gid` as a substitute (by a single, + /// multiple, alternate or ligature substitution), a listed feature ([`Self::substitutions`]) + /// before another (`init`, `liga`), if any does. Not read through a contextual lookup. + pub fn feature_reaching(&self, gid: u32) -> Option<[u8; 4]> { + self.gsub_index().reached.get(&gid).map(|r| r.0) + } + + /// Where glyph `gid` comes from: the character whose glyph the `GSUB` table's substitutions + /// lead to it from, and the features on the way, `gid`'s own first (`aalt`, then `fina` for + /// `yehfarsi.fina.alt2`, whose character is U+06CC). The chain follows the first substitution + /// that gives each glyph back to a glyph none gives, as far as [`MAX_ORIGIN_STEPS`] steps; a + /// presentation-form character that maps a glyph in the chain does not stop it, and the + /// character is the lowest one outside the Arabic presentation forms that maps the glyph it + /// ends on (the lowest of all when only presentation forms do). `None` when no character maps + /// that glyph (a glyph no feature reaches, a ligature). + pub fn glyph_origin(&self, gid: u32) -> Option { + let index = self.gsub_index(); + let mut features = vec![]; + let mut at = gid; + for _ in 0..MAX_ORIGIN_STEPS { + let Some((tag, from)) = index.reached.get(&at) else { break }; + features.push(*tag); + at = (*from)?; + } + let font = self.skrifa()?; + let presentation = |c: u32| (0xFB50..=0xFDFF).contains(&c) || (0xFE70..=0xFEFF).contains(&c); + let mut chars: Vec = font.charmap().mappings().filter(|(_, g)| g.to_u32() == at).map(|(c, _)| c).collect(); + chars.sort_by_key(|c| (presentation(*c), *c)); + let ch = chars.first().copied().and_then(char::from_u32)?; + Some(GlyphOrigin { ch, features }) + } + + /// The name of glyph `gid` from the font's `post` or `CFF` table: `None` for a glyph past the + /// face's glyphs, or when the font has no names (the ids are then `gid12`). + pub fn glyph_name(&self, gid: u32) -> Option { + let font = self.skrifa()?; + let name = font.glyph_names().get(skrifa::GlyphId::new(gid))?; + (!name.is_synthesized()).then(|| name.as_str().to_string()) + } +} diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs index 435bbc4ed7..b181672569 100644 --- a/crates/text/src/harakat.rs +++ b/crates/text/src/harakat.rs @@ -37,10 +37,11 @@ pub(crate) fn is_harakat(c: char) -> bool { } /// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the -/// stroke is drawn, overprint, and the character style name? Those are not read by the shaper -/// and change no glyph, advance or position. Nor is the space before a line's first character -/// inside a cluster, which takes it from its first character (#5111), so it is ignored too. Stricter than [`crate::shape`]'s `shapes_alike` -/// (which also ignores baseline shift, tracking, underline, …). +/// stroke is drawn, overprint, and the character style name? OpenType feature differences are +/// ignored here: they already apply to their own character ranges during shaping. Nor is the +/// space before a line's first character read inside a cluster, which takes it from its first +/// character (#5111). Stricter than [`crate::shape`]'s `shapes_alike` (which also ignores baseline +/// shift, tracking, underline, …). pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { // Exhaustive: a new attribute must explicitly keep or relax this boundary rule. let CharStyle { @@ -61,7 +62,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { underline, strikethrough, all_caps, - features, + features: _, position, small_caps, char_align, @@ -110,7 +111,6 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { && underline == &b.underline && strikethrough == &b.strikethrough && all_caps == &b.all_caps - && features == &b.features && position == &b.position && small_caps == &b.small_caps && char_align == &b.char_align @@ -129,7 +129,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { } /// Is the run boundary at byte `at` of `text`, between styles `before` and `after`, one that -/// stays inside a shaping cluster: they differ only in paint and a harakat is on either side? +/// stays inside a shaping cluster: only paint or features differ and a harakat is on either side? pub(crate) fn paint_only_mark_boundary(text: &str, at: usize, before: &CharStyle, after: &CharStyle) -> bool { let next = text.get(at..).and_then(|s| s.chars().next()); let previous = text.get(..at).and_then(|s| s.chars().next_back()); diff --git a/crates/text/src/justify.rs b/crates/text/src/justify.rs index 53de928be2..3a4f561fb7 100644 --- a/crates/text/src/justify.rs +++ b/crates/text/src/justify.rs @@ -48,14 +48,61 @@ impl Flex { } } -/// A glyph justification spreads space over as a word space (tabs keep their stops). +/// A glyph justification spreads space over as a word space (tabs keep their stops). The +/// zero-width space is a line break opportunity but not a word separator, so it takes none. pub(crate) fn is_word_space(g: &SGlyph) -> bool { - g.is_space() && g.ch != '\t' + g.is_space() && g.ch != '\t' && !is_format_control(g.ch) } -/// A glyph letter spacing follows: a visible glyph followed by another one in the same word. +/// A zero-width format control: an assigned default-ignorable character (Unicode's +/// Default_Ignorable_Code_Point) that is drawn with no width. That is a soft hyphen, a bidi mark, +/// embedding, override or isolate (the Arabic letter mark included), a zero-width space, +/// non-joiner or joiner, a word joiner or invisible operator, a combining grapheme joiner, a +/// variation selector, a Mongolian free variation selector or vowel separator, a shorthand or +/// musical format control, a tag, or the byte order mark. Letter spacing and justification add no +/// space next to one; the space goes between the visible characters on either side. The +/// zero-width space stays a line break opportunity ([`SGlyph::is_space`]), and it takes the letter +/// spacing of the letter before it, which a line that breaks there trims. The Hangul fillers, +/// which fonts draw with a width, are letters. +pub(crate) fn is_format_control(ch: char) -> bool { + matches!( + ch, + '\u{00AD}' + | '\u{034F}' + | '\u{061C}' + | '\u{17B4}'..='\u{17B5}' + | '\u{180B}'..='\u{180F}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{206F}' + | '\u{FE00}'..='\u{FE0F}' + | '\u{FEFF}' + | '\u{1BCA0}'..='\u{1BCA3}' + | '\u{1D173}'..='\u{1D17A}' + | '\u{E0000}'..='\u{E0FFF}' + ) +} + +/// The zero-width space: a format control that is a line break opportunity. +const ZERO_WIDTH_SPACE: char = '\u{200B}'; + +/// A glyph that may take the letter spacing before the next character: a visible glyph that is +/// not a space, a line break, an inline graphic or a format control. +fn gap_owner(g: &SGlyph) -> bool { + !g.is_space() && g.ch != '\u{2028}' && g.inline.is_none() && !is_format_control(g.ch) +} + +/// Whether `next`, the glyph after `g` (passing over format controls other than the zero-width +/// space), continues the word: a visible character of another shaped cluster. +fn continues_word(g: &SGlyph, next: Option<&SGlyph>) -> bool { + next.is_some_and(|n| n.byte != g.byte && !n.is_space() && n.ch != '\u{2028}') +} + +/// A glyph letter spacing follows: a visible glyph followed by another character in the same word +/// (`next` is the next glyph that is not a format control, or a zero-width space). The glyphs of +/// one shaped cluster (a letter and its combining marks) take none between them. fn letter_gap(g: &SGlyph, next: Option<&SGlyph>) -> bool { - !g.is_space() && g.ch != '\u{2028}' && g.inline.is_none() && next.is_some_and(|n| !n.is_space() && n.ch != '\u{2028}') + gap_owner(g) && continues_word(g, next) } /// The width of a word space in `g`'s font and size: what letter spacing is a share of. @@ -73,9 +120,33 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g let [l_min, l_des, l_max] = j.letter_spacing.map(pct); let [g_min, g_des, g_max] = if scale_glyphs { j.glyph_scaling.map(pct) } else { [1.0; 3] }; let mut flex = vec![Flex::default(); sg.len()]; + // The last glyph before `k` that is not a format control, when that glyph may take a letter gap. + let mut owner: Option = None; for k in 0..sg.len() { - let gap = letter_gap(&sg[k], sg.get(k + 1)); + let ch = sg[k].ch; + let zero_width_space = ch == ZERO_WIDTH_SPACE; + // Only a glyph that is not a format control, or a zero-width space, looks past the + // controls after it, up to the next zero-width space, so each run of controls is passed + // over once. + let scans = zero_width_space || !is_format_control(ch); + let next = + if scans { sg.get(k + 1..).and_then(|rest| rest.iter().find(|n| !is_format_control(n.ch) || n.ch == ZERO_WIDTH_SPACE)) } else { None }; + // A letter followed by a zero-width space takes no gap. The zero-width space takes the + // letter's gap instead (the letter's space width, after its glyph scaling), so a line + // that breaks there ends without letter spacing. + let space_gap = if zero_width_space && continues_word(&sg[k], next) { owner.and_then(|o| sg.get(o)).map(space_width) } else { None }; + let gap = !zero_width_space && scans && letter_gap(&sg[k], next); + if !is_format_control(ch) { + owner = None; + } let (Some(g), Some(f)) = (sg.get_mut(k), flex.get_mut(k)) else { continue }; + if zero_width_space { + if let Some(space) = space_gap { + g.adv += space * l_des; + f.letter = (space * (l_max - l_des).max(0.0), space * (l_des - l_min).max(0.0)); + } + continue; + } if is_word_space(g) { // The font's word space (with its tracking), set at Desired. let base = g.adv; @@ -102,6 +173,9 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g g.adv += space * l_des; f.letter = (space * (l_max - l_des).max(0.0), space * (l_des - l_min).max(0.0)); } + if gap_owner(g) { + owner = Some(k); + } } flex } diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 0f033d4fcb..0ac57e0833 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -4,6 +4,7 @@ use super::{ Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, KinsokuShoriType, LeadingModel, SGlyph, hyphen_glyph, is_cjk, no_line_end, no_line_start, points_for, soft_line_start, }; +use crate::justify::is_format_control; /// Vertical metrics of a line: (ascent, descent, leading, cap height, x height). #[derive(Clone, Copy, Debug, PartialEq)] @@ -659,7 +660,7 @@ fn push_in( || (a + 2 == last && punct(last - 1)) || (a + 3 == last && punct(last - 2) && quote(last - 1))) }; - let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum(); + let room: f64 = (i..end - 1).filter(|&a| closes(g, a, end) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum(); if x - line_end_credit(g, line_end, last) - width > room + EPS { return None; } @@ -685,9 +686,11 @@ fn push_in( /// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them /// CJK, outside a tate-chu-yoko block, a list marker and a shaped cluster, and not next to a space, -/// a tab or an inline graphic before it. -fn closes(g: &[SGlyph], a: usize) -> bool { - let (Some(p), Some(n)) = (g.get(a), g.get(a + 1)) else { return false }; +/// a tab or an inline graphic before it. Zero-width format controls take no gap: the gap is the +/// one between the visible characters on either side, before `end`, as the layout justifies it. +fn closes(g: &[SGlyph], a: usize, end: usize) -> bool { + let Some(p) = g.get(a).filter(|p| !is_format_control(p.ch)) else { return false }; + let Some(n) = g.get(a + 1..end).and_then(|rest| rest.iter().find(|n| !is_format_control(n.ch))) else { return false }; p.len > 0 && p.byte != n.byte && p.inline.is_none() diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index d7ac206424..79b8f4326b 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -10,6 +10,7 @@ use super::{ ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, }; +use crate::justify::is_format_control; use crate::shape::keep_korean_words; /// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First, @@ -707,14 +708,56 @@ fn drawn_after(index: usize, glyph: usize, after: &[Option]) -> Option], sg: &[SGlyph]) -> Option { + let mut next = drawn_after(index, glyph, after); + for _ in 0..after.len() { + match next { + Some(n) if sg.get(n).is_some_and(|g| is_format_control(g.ch)) => next = drawn_after(index, n, after), + _ => break, + } + } + next +} + +// The glyph after which a gap between drawn glyphs opens, and the visible glyph that follows it: +// not a held glyph, and never a zero-width format control on either side. +fn gap_pair<'a>(glyph: usize, held: &[usize], after: &[Option], index: usize, sg: &'a [SGlyph]) -> Option<(&'a SGlyph, &'a SGlyph)> { + if held.contains(&glyph) { + return None; + } + let a = sg.get(glyph).filter(|a| !is_format_control(a.ch))?; + visible_after(index, glyph, after, sg).and_then(|next| sg.get(next)).map(|b| (a, b)) +} + // Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the // gaps next to a CJK character, not inside a Latin word, a tate-chu-yoko block or a shaped // cluster. fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option], index: usize, sg: &[SGlyph]) -> bool { - !held.contains(&glyph) - && drawn_after(index, glyph, after).and_then(|next| sg.get(glyph).zip(sg.get(next))).is_some_and(|(a, b)| { - a.byte != b.byte && !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)) - }) + gap_pair(glyph, held, after, index, sg).is_some_and(|(a, b)| { + a.byte != b.byte && !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)) + }) +} + +// Single Word Justification's Full Justify spreads a line's leftover room over the gaps between +// its drawn glyphs: not after a held glyph, nor inside a tate-chu-yoko block (one cell) or a +// shaped cluster, whose glyphs (a letter and its combining marks, a conjunct and its matra) stay +// together. On a line that ends in a drawn hyphen, the gap before the hyphen counts too: it follows +// the line's last visible glyph. +fn is_letter_gap(glyph: usize, held: &[usize], after: &[Option], index: usize, sg: &[SGlyph], hyphen: bool) -> bool { + if held.contains(&glyph) || sg.get(glyph).is_none_or(|a| is_format_control(a.ch)) { + return false; + } + match gap_pair(glyph, held, after, index, sg) { + Some((a, b)) => a.byte != b.byte && !b.continues_tcy(), + None => hyphen, + } +} + +// A space that takes a share of a justified line's room: not a zero-width space, which is a line +// break opportunity but not a word separator. +fn is_justified_space(g: &SGlyph) -> bool { + g.is_space() && !is_format_control(g.ch) } // May a pushed-in line close a CJK gap after `g`? Not after a list marker or an inline graphic, @@ -760,7 +803,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin Justify::JustifyAll => (0, true), }; justify &= paragraph.regions.is_some(); - let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && sg[j].is_space()).count(); + let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).count(); // A gap that justification adds between two glyphs goes into the advance of the one // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a // right-to-left line comes before it in the text. @@ -812,7 +855,8 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin // Word spacing, letter spacing and glyph scaling within their ranges, then the // word spaces alone; composed lines may shrink word spaces (never below zero). let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); - let floor = (line.index..line.body).filter(|&j| j >= paragraph.marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min); + let floor = + (line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).map(|j| sg[j].adv).fold(f64::MAX, f64::min); fit = fit_line(&flex, room - width, spaces, floor); } else if room > width { // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. @@ -820,11 +864,8 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin fit = fit_line(&flex, room - width, 0, 0.0); let used: f64 = flex.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); let rest = room - width - used; - let gaps = (line.index..line.body) - .filter(|&j| { - !held.contains(&j) && drawn_after(line.index, j, &after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) - }) - .count(); + let hyphen = line.hyphen.is_some(); + let gaps = (line.index..line.body).filter(|&j| is_letter_gap(j, held, &after, line.index, sg, hyphen)).count(); if gaps > 0 && rest > EPS { per_gap = rest / gaps as f64; } @@ -942,16 +983,13 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData advance += justification.fit.letter(&flex) + justification.fit.glyph(&flex); scale = justification.fit.glyph_scale(&flex); } - if glyph.is_space() { + if is_justified_space(glyph) { advance += justification.per_space; } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical); } - } else if !line.held.contains(&glyph_index) - && drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) - { - // Between glyphs, never inside a tate-chu-yoko block (one cell). + } else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) { advance += justification.per_gap; } } diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 0c8ffb4e2f..dde3593b9d 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -426,6 +426,21 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() { assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in"); } +/// A zero-width joiner between two CJK characters takes no gap of its own: the layout closes the +/// one gap between the characters on either side, so the push-in counts five gaps (5 pt), short of +/// the 6 pt the line overflows by. +#[test] +fn kinsoku_shori_type_counts_no_gap_at_a_format_control() { + let text = "一二\u{200D}三四五」六"; + let mut glyphs = full_width(text); + glyphs[2].adv = 0.0; + let end = |width: f64| { + break_line(text, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushInFirst, false, false, &[]).0 + }; + assert_eq!(end(54.0), 5, "五 is pushed out with 」"); + assert_eq!(end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in"); +} + /// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed /// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last /// glyph of the last cluster counts, and only where the line may end. diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d2e3cf1dc8..96b51e7b4c 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -13,6 +13,7 @@ mod bitmap; mod colr; mod composer; +mod context_alternates; pub mod craft_fonts; pub mod edit; pub mod embed; @@ -34,12 +35,16 @@ pub mod test_fonts; pub mod thread; pub use bitmap::GlyphImage; +pub use context_alternates::{ContextAlternate, ContextAlternates, alternate_changes, alternates_in_context, is_alternate_feature}; pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont}; -pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; +pub use features::{ + EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_FEATURE_VALUE, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by, +}; pub use fontdb::{ - CompositeCoverage, FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, LentCompositeFonts, - PlatformFontFiles, StylisticSet, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, - set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs, + CompositeCoverage, FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, GlyphOrigin, IcfMargins, JoiningPosition, + LentCompositeFonts, PlatformFontFiles, StylisticSet, Substitution, SubstitutionKind, WantedFont, WantedFonts, app_font_dir, is_font_file, + is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, + user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; pub use layout::{envelope_ink, register_envelope_ink}; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index ba7339bc6b..c1e2b9e0f5 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -875,33 +875,113 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: source.get(i).filter(|(range, _)| range.contains(&byte)).map(|_| span.start + i) } -/// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that -/// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the -/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. The space -/// before a line's first character ([`space_before`]) is never read inside a cluster, which takes it -/// from its first character, so it doesn't split a ligature either. +/// Can one glyph draw characters in styles `a` and `b`? OpenType features already apply to their +/// own character ranges during shaping, so feature-only differences do not split a cluster. +/// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets. +/// The space before a line's first character ([`space_before`]) comes from the cluster's first +/// character, so it does not split a ligature either. fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { - let b = &CharStyle { kerning_before: a.kerning_before, ..b.clone() }; - if a == b { - return true; - } - !vertical - && *a - == CharStyle { - standard_vertical_roman: a.standard_vertical_roman, - tcy_up_down: a.tcy_up_down, - tcy_left_right: a.tcy_left_right, - ..b.clone() - } + // Exhaustive, like `shapes_alike`: compare borrowed fields without copying feature lists. + let CharStyle { + font_family, + font_style, + font_version, + font_axes, + size, + leading, + tracking, + kerning, + kerning_before, + optical_kerning, + baseline_shift, + h_scale, + v_scale, + rotation, + fill, + stroke, + stroke_width, + underline, + strikethrough, + all_caps, + features: _, + style_name, + overprint_fill, + overprint_stroke, + stroke_cap, + stroke_join, + stroke_miter_limit, + stroke_dash, + stroke_align, + position, + small_caps, + char_align, + char_direction, + kashidas, + proportional_metrics, + tate_chu_yoko, + tcy_up_down, + tcy_left_right, + standard_vertical_roman, + digits, + font_height_reference, + no_break, + language, + glyph_alternate, + } = a; + // Keep PartialEq's non-reflexive behavior for ignored numeric fields, without cloning styles. + let offsets = (standard_vertical_roman, tcy_up_down, tcy_left_right); + let other_offsets = if vertical { (&b.standard_vertical_roman, &b.tcy_up_down, &b.tcy_left_right) } else { offsets }; + kerning_before == &a.kerning_before + && offsets == other_offsets + && font_family == &b.font_family + && font_style == &b.font_style + && font_version == &b.font_version + && font_axes == &b.font_axes + && size == &b.size + && leading == &b.leading + && tracking == &b.tracking + && kerning == &b.kerning + && optical_kerning == &b.optical_kerning + && baseline_shift == &b.baseline_shift + && h_scale == &b.h_scale + && v_scale == &b.v_scale + && rotation == &b.rotation + && fill == &b.fill + && stroke == &b.stroke + && stroke_width == &b.stroke_width + && underline == &b.underline + && strikethrough == &b.strikethrough + && all_caps == &b.all_caps + && style_name == &b.style_name + && overprint_fill == &b.overprint_fill + && overprint_stroke == &b.overprint_stroke + && stroke_cap == &b.stroke_cap + && stroke_join == &b.stroke_join + && stroke_miter_limit == &b.stroke_miter_limit + && stroke_dash == &b.stroke_dash + && stroke_align == &b.stroke_align + && position == &b.position + && small_caps == &b.small_caps + && char_align == &b.char_align + && char_direction == &b.char_direction + && kashidas == &b.kashidas + && proportional_metrics == &b.proportional_metrics + && tate_chu_yoko == &b.tate_chu_yoko + && digits == &b.digits + && font_height_reference == &b.font_height_reference + && no_break == &b.no_break + && language == &b.language + && glyph_alternate == &b.glyph_alternate } /// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text? /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the /// character style's name, leading, baseline shift, rotation, Character Alignment, Character /// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its -/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]) -/// and the tracking, as long as the tracking keeps or drops standard -/// ligatures alike ([`crate::features::ligatures_suppressed_by`]). +/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]), +/// the OpenType features (each run's apply to its own characters, as features over a range of the +/// one buffer) and the tracking, as long as the tracking keeps or drops standard ligatures alike +/// ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { // Every field is named, so that a new attribute has to be sorted into one group or the other. let CharStyle { @@ -925,7 +1005,9 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { underline: _, strikethrough: _, all_caps, - features, + // Runs with different feature lists shape as one buffer: each run's features apply to its + // own characters only ([`shape_segment`]). + features: _, style_name: _, overprint_fill: _, overprint_stroke: _, @@ -958,7 +1040,6 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { && *h_scale == b.h_scale && *v_scale == b.v_scale && *all_caps == b.all_caps - && *features == b.features && *position == b.position && *small_caps == b.small_caps && *proportional_metrics == b.proportional_metrics @@ -1121,6 +1202,23 @@ struct Shaped { chars: Vec, } +/// The features that turn the whole-text features `base` into `own` over the clusters `range`, put +/// after `base` so that they win there: each feature of `own` that `base` lacks or has with +/// another value, and each of `base` that `own` leaves out, back at the value the shaper has +/// without it (on for the default features, off for the rest). The range is given with an +/// inclusive end because harfrust's `Feature::new` takes an exclusive end one cluster short, but +/// applies it as exclusive: a one-byte character would get an empty range. +fn ranged_features(base: &[Feature], own: &[Feature], range: Range, out: &mut Vec) { + const ON_BY_DEFAULT: [&[u8; 4]; 7] = [b"kern", b"liga", b"clig", b"calt", b"apkn", b"vkrn", b"vapk"]; + for f in own.iter().filter(|f| !base.iter().any(|b| b.tag == f.tag && b.value == f.value)) { + out.push(Feature::new(f.tag, f.value, range.start..=range.end)); + } + for b in base.iter().filter(|b| !own.iter().any(|f| f.tag == b.tag)) { + let default = ON_BY_DEFAULT.iter().any(|t| Tag::new(t) == b.tag); + out.push(Feature::new(b.tag, u32::from(default), range.start..=range.end)); + } +} + fn is_mark(c: char) -> bool { use unicode_general_category::{GeneralCategory, get_general_category}; matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark) @@ -1132,8 +1230,8 @@ fn is_mark(c: char) -> bool { /// characters with automatic kerning: the manual value replaces the font's pair after its /// character, and a character before it with automatic kerning keeps the font's pair with it /// (except before a character that All Caps or Small Caps sets as several letters, such as ß as -/// SS). A cluster that would take in characters of runs with different styles (a ligature, a -/// base with its marks) is not formed: the segment is shaped in pieces at those run boundaries. +/// SS). A cluster that would take in characters of runs with different drawing attributes (a +/// ligature, a base with its marks) is not formed, except for paint-only harakat differences. fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFeatures, out: &mut Vec) { let Segment { range, runs, source_runs, st, face, small, level } = seg; let (range, small) = (range.clone(), *small); @@ -1196,6 +1294,23 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let styled_marks = !mark_boundaries.is_empty(); let char_level = std::cell::Cell::new(false); + // Runs that differ only in their OpenType feature lists are shaped as one buffer: the features + // of each run apply to its own characters only (a feature with a range of clusters, which here + // are source bytes), over those of `base_style`, whose features apply to the whole segment. So + // a feature or an alternate (`salt=2`) on one letter of an Arabic word leaves the joining, + // ligatures and kerning of the whole word to the shaper, as the same text without it. + // `variant` makes a style's features and `base` is what it makes for `base_style`. + let run_features = |variant: &dyn Fn(&CharStyle) -> Vec, base_style: &CharStyle, base: &[Feature]| { + let mut ranged = Vec::new(); + for (r, run_style) in runs.get(source_runs.clone()).unwrap_or_default() { + let (a, b) = (r.start.max(range.start), r.end.min(range.end)); + if run_style.features != base_style.features && a < b { + let own = variant(&CharStyle { features: run_style.features.clone(), ..base_style.clone() }); + ranged_features(base, &own, a..b, &mut ranged); + } + } + ranged + }; // Shape the segment as if all of it were in `st`, with the features `more` on top. let shape_in = |st: &CharStyle, more: &[Feature]| { // A BCP 47 tag is short ASCII; anything else (a file may hold anything) is shaped without one. @@ -1210,8 +1325,13 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); // The baseline and character-level pass use exactly the same resolved features. // Alternate positioning passes still resolve their own features, with `more` last. - let with_more = |resolved: Vec| resolved.into_iter().chain(more.iter().cloned()).collect::>(); - let along_features = with_more(feats.resolve(st)); + let with_more = |variant: &dyn Fn(&CharStyle) -> Vec| { + let mut resolved = variant(st); + resolved.extend(run_features(variant, st, &resolved)); + resolved.extend(more.iter().cloned()); + resolved + }; + let along_features = with_more(&|s| feats.resolve(s)); // gid, cluster, x and y advance, x and y offset. let shape = |feats: &[Feature], direction: Direction| { let mut buf = UnicodeBuffer::new(); @@ -1274,7 +1394,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe if feats.proportional(st) { // `palt` is positioning only: the same glyphs set on their full widths tell which // advances it changed (kerning and the rest change both alike). - let full = shape(&with_more(feats.resolve_fixed_width(st)), along); + let full = shape(&with_more(&|s| feats.resolve_fixed_width(s)), along); if same_glyphs(&full) { *proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect(); } @@ -1285,7 +1405,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe // and without either tell how it moves each and changes its advance down the column // (y up, advances negative). Which advances `vpal` itself changed comes from setting // them with it alone. - let ttb = |proportional, kerning| shape(&with_more(feats.resolve_vertical(st, proportional, kerning)), Direction::TopToBottom); + let ttb = |proportional, kerning| shape(&with_more(&|s| feats.resolve_vertical(s, proportional, kerning)), Direction::TopToBottom); let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false)); if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) { *vpal = with @@ -1385,8 +1505,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe cluster_starts.dedup(); // A glyph is drawn in one style: where a cluster would take in characters of runs in different // styles, the runs are shaped apart, as runs that do not shape alike are. - // Except at harakat styled apart only in paint ([`crate::harakat`]), and, in horizontal type, where - // the runs differ only in settings that vertical type alone reads. + // Except at harakat styled apart only in paint ([`crate::harakat`]), feature-only boundaries, + // and, in horizontal type, settings that vertical type alone reads. let inside_clusters: Vec = boundaries() .filter(|&b| cluster_starts.binary_search(&b).is_err() && !drawn_alike(run_at(b - 1).1, run_at(b).1, feats.vertical)) .filter(|b| mark_boundaries.binary_search(b).is_err()) diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 1300920ad1..2d766ae571 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -128,6 +128,41 @@ pub fn variable_font() -> Option> { Some(builder.build()) } +/// The family of [`unlisted_variable_font`]. +pub const UNLISTED_VARIABLE_FAMILY: &str = "Unlisted Varitest Sans"; + +/// A variable face with no GSUB: `a.a` is an unencoded alternate named for `a`, but the Glyphs +/// panel has no listed alternates. The weight axis comes from [`variable_font`]. +pub fn unlisted_variable_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + + let bytes = variable_font()?; + let font = skrifa::FontRef::new(&bytes).ok()?; + let mappings = [' ', 'a', 'l'] + .into_iter() + .map(|c| Some((c, write_fonts::types::GlyphId::new(font.charmap().map(c)?.to_u32())))) + .collect::>>()?; + let cmap = Cmap::from_mappings(mappings).ok()?; + let name = name_table(&[ + (1, UNLISTED_VARIABLE_FAMILY), + (2, "Regular"), + (4, "Unlisted Varitest Sans Regular"), + (5, "Version 1.000"), + (6, "UnlistedVaritestSans-Regular"), + (256, "Weight"), + ])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + builder.add_table(&cmap).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if tag != Tag::new(b"GSUB") && !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`two_axis_font`]. pub const TWO_AXIS_FAMILY: &str = "Biaxtest Sans"; @@ -791,6 +826,211 @@ pub fn alternates_font(aalt: bool) -> Option> { Some(builder.build()) } +/// The family of [`capped_candidates_font`]. +pub const CAPPED_CANDIDATES_FAMILY: &str = "Captest Sans"; + +/// The bundled Source Sans 3 Regular, its `cmap` kept, with a `GSUB` table for `a` of an `aalt` +/// alternate substitution that lists `b` 512 times (the longest set read: 512 values to try) and a +/// `salt` single substitution that gives `c`. Family [`CAPPED_CANDIDATES_FAMILY`]. `None` when it +/// can't be built. +pub fn capped_candidates_font() -> Option> { + use write_fonts::tables::gsub::{ + AlternateSet, AlternateSubstFormat1, Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList, + }; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let name = + name_table(&[(1, CAPPED_CANDIDATES_FAMILY), (2, "Regular"), (4, "Captest Sans Regular"), (5, "Version 1.000"), (6, "CaptestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let a = gid('a')?; + let cover = || CoverageFormat1::new(vec![a]); + let alternates = AlternateSubstFormat1::new(cover().into(), vec![AlternateSet::new(vec![gid('b')?; 512])]); + let salt = SingleSubst::from(SingleSubstFormat2::new(cover().into(), vec![gid('c')?])); + let lookups = SubstitutionLookupList::new(vec![ + SubstitutionLookup::Alternate(Lookup::new(Default::default(), vec![alternates])), + SubstitutionLookup::Single(Lookup::new(Default::default(), vec![salt])), + ]); + let records = + vec![FeatureRecord::new(Tag::new(b"aalt"), Feature::new(None, vec![0])), FeatureRecord::new(Tag::new(b"salt"), Feature::new(None, vec![1]))]; + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0, 1])), vec![]))]); + builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// The family of [`joining_alternates_font`]. +pub const JOINING_ALTERNATES_FAMILY: &str = "Jointest Sans"; + +/// Beh, teh, noon and alef: the letters [`joining_alternates_font`] maps. +pub const JOINING_LETTERS: [char; 4] = ['\u{0628}', '\u{062A}', '\u{0646}', '\u{0627}']; + +/// A test-only Arabic font over the bundled Source Sans 3, whose glyphs stand for the positional +/// forms of beh, teh, noon and alef ([`JOINING_LETTERS`]), each a Latin letter's outline: isolated +/// b t n I, initial B T N, medial v u m, final V U M A (alef joins to the right only). Its `arab` +/// `GSUB` has `init`, `medi` and `fina` single substitutions from the isolated glyphs, `salt` +/// alternate substitutions of the medial teh u (w, x, y: three alternates), of the final teh U +/// (W, X), of the isolated teh t (E) and of the medial beh v (z), and `ss01` single substitutions +/// u to w, U to W, t to E and v to z. A set differs by position, as in a font that draws each +/// letter's alternates for the position they join in. `cv01` gives the medial noon m the glyph d, +/// an alternate that has no style tag. Family [`JOINING_ALTERNATES_FAMILY`]. `None` when it can't +/// be built. +pub fn joining_alternates_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + use write_fonts::tables::gsub::{ + AlternateSet, AlternateSubstFormat1, Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList, + }; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + let font = skrifa::FontRef::new(SOURCE_SANS).ok()?; + let charmap = font.charmap(); + let gid = |c: char| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let family = JOINING_ALTERNATES_FAMILY; + let full = format!("{family} Regular"); + let postscript = format!("{}-Regular", family.replace(' ', "")); + let name = name_table(&[(1, family), (2, "Regular"), (4, &full), (5, "Version 1.000"), (6, &postscript)])?; + let mapping = [(JOINING_LETTERS[0], 'b'), (JOINING_LETTERS[1], 't'), (JOINING_LETTERS[2], 'n'), (JOINING_LETTERS[3], 'I'), (' ', ' ')]; + let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); + let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + // A single substitution by (glyph, replacement) pairs, sorted by glyph id as coverage needs. + let single = |pairs: &[(char, char)]| { + let mut ids = pairs.iter().map(|(from, to)| Some((gid(*from)?, gid(*to)?))).collect::>>()?; + ids.sort_by_key(|p| p.0.to_u16()); + let from = ids.iter().map(|p| p.0).collect(); + let to = ids.iter().map(|p| p.1).collect(); + Some(SubstitutionLookup::Single(Lookup::new( + Default::default(), + vec![SingleSubst::from(SingleSubstFormat2::new(CoverageFormat1::new(from).into(), to))], + ))) + }; + let alternates = |sets: &[(char, &[char])]| { + let mut ids = sets + .iter() + .map(|(from, set)| Some((gid(*from)?, set.iter().map(|c| gid(*c)).collect::>>()?))) + .collect::>>()?; + ids.sort_by_key(|p| p.0.to_u16()); + let from = ids.iter().map(|p| p.0).collect(); + let sets = ids.into_iter().map(|p| AlternateSet::new(p.1)).collect(); + Some(SubstitutionLookup::Alternate(Lookup::new( + Default::default(), + vec![AlternateSubstFormat1::new(CoverageFormat1::new(from).into(), sets)], + ))) + }; + // Lookups: 0 init, 1 medi, 2 fina, 3 salt, 4 ss01, 5 cv01. + let lookups = SubstitutionLookupList::new(vec![ + single(&[('b', 'B'), ('t', 'T'), ('n', 'N')])?, + single(&[('b', 'v'), ('t', 'u'), ('n', 'm')])?, + single(&[('b', 'V'), ('t', 'U'), ('n', 'M'), ('I', 'A')])?, + alternates(&[('u', &['w', 'x', 'y']), ('U', &['W', 'X']), ('t', &['E']), ('v', &['z'])])?, + single(&[('u', 'w'), ('U', 'W'), ('t', 'E'), ('v', 'z')])?, + single(&[('m', 'd')])?, + ]); + // Features sorted by tag, as OpenType requires: cv01, fina, init, medi, salt, ss01. + let records: Vec = [(b"cv01", 5), (b"fina", 2), (b"init", 0), (b"medi", 1), (b"salt", 3), (b"ss01", 4)] + .into_iter() + .map(|(tag, lookup)| FeatureRecord::new(Tag::new(tag), Feature::new(None, vec![lookup]))) + .collect(); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0, 1, 2, 3, 4, 5])), vec![]))]); + builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// The family of [`aliased_joining_font`]. +pub const ALIASED_JOINING_FAMILY: &str = "Alias Jointest Sans"; + +/// [`joining_alternates_font`] with Latin `B` and the Arabic initial beh presentation form +/// U+FE91 both mapped to beh's initial glyph. Only the presentation form should insert beh. +pub fn aliased_joining_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + + let bytes = joining_alternates_font()?; + let font = skrifa::FontRef::new(&bytes).ok()?; + let source = skrifa::FontRef::new(SOURCE_SANS).ok()?; + let initial = write_fonts::types::GlyphId::new(source.charmap().map('B')?.to_u32()); + let mut mappings = + font.charmap().mappings().filter_map(|(c, g)| Some((char::from_u32(c)?, write_fonts::types::GlyphId::new(g.to_u32())))).collect::>(); + mappings.extend([('B', initial), ('\u{FE91}', initial)]); + let cmap = Cmap::from_mappings(mappings).ok()?; + let name = name_table(&[ + (1, ALIASED_JOINING_FAMILY), + (2, "Regular"), + (4, "Alias Jointest Sans Regular"), + (5, "Version 1.000"), + (6, "AliasJointestSans-Regular"), + ])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + builder.add_table(&cmap).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// The family and private-use character of [`contextual_fallback_font`]. +pub const CONTEXTUAL_FALLBACK_FAMILY: &str = "Contextual Fallbacktest Sans"; +pub const CONTEXTUAL_FALLBACK_CHAR: char = '\u{F6084}'; + +/// A fallback fixture with coverage no bundled face shares: `ccmp` draws its private-use +/// character (n) as m, whose `cv01` alternate d has no applicable character-style feature. +pub fn contextual_fallback_font() -> Option> { + use write_fonts::tables::gsub::{Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList}; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + let base = remapped_source_sans( + CONTEXTUAL_FALLBACK_FAMILY, + "ContextualFallbacktestSans-Regular", + &[(CONTEXTUAL_FALLBACK_CHAR, 'n'), ('a', 'a'), (' ', ' ')], + )?; + let font = skrifa::FontRef::new(&base).ok()?; + let [n, m, d] = source_sans_gids(['n', 'm', 'd'])?; + let single = |from, to| { + SubstitutionLookup::Single(Lookup::new( + Default::default(), + vec![SingleSubst::from(SingleSubstFormat2::new(CoverageFormat1::new(vec![GlyphId16::new(from)]).into(), vec![GlyphId16::new(to)]))], + )) + }; + let lookups = SubstitutionLookupList::new(vec![single(n, m), single(m, d)]); + let features = FeatureList::new(vec![ + FeatureRecord::new(Tag::new(b"ccmp"), Feature::new(None, vec![0])), + FeatureRecord::new(Tag::new(b"cv01"), Feature::new(None, vec![1])), + ]); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0, 1])), vec![]))]); + let mut builder = FontBuilder::new(); + builder.add_table(&Gsub::new(scripts, features, lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`ligature_caret_font`]. pub const LIGATURE_CARET_FAMILY: &str = "Carettest Sans"; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index bcb233b8c3..30713d33aa 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -13,6 +13,7 @@ mod composite; mod devanagari; mod embed; mod fit; +mod glyph_alternates; mod hang; mod harakat; mod inline; diff --git a/crates/text/src/tests/devanagari.rs b/crates/text/src/tests/devanagari.rs index f7cca3093e..9e7f370f36 100644 --- a/crates/text/src/tests/devanagari.rs +++ b/crates/text/src/tests/devanagari.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use vectorcraft_doc::{CharStyle, TextKind}; use vectorcraft_geom::PathData; -fn devanagari_test_face(test: &str) -> Option> { +pub(super) fn devanagari_test_face(test: &str) -> Option> { let db = FontDb::global(); // The face must have OpenType (GSUB) Devanagari lookups. An AAT face such as macOS's Devanagari MT // shapes with `morx`, and हिन्दी comes out as three clusters because the conjunct न्दी is split in two. diff --git a/crates/text/src/tests/glyph_alternates.rs b/crates/text/src/tests/glyph_alternates.rs new file mode 100644 index 0000000000..6a47a3b0ee --- /dev/null +++ b/crates/text/src/tests/glyph_alternates.rs @@ -0,0 +1,504 @@ +//! A feature with a value (`salt=2`) on some characters of an Arabic word: the font's alternate +//! for them, with the word shaped once so that every other letter keeps its joining form and the +//! letters still touch. Read from a font built in code (and, when it is installed, the owner's +//! Kufi Murabba PIIIX). + +use kurbo::Point; +use vectorcraft_doc::{CharStyle, TextRun}; + +use crate::test_fonts::{JOINING_ALTERNATES_FAMILY, JOINING_LETTERS, joining_alternates_font, source_sans_gid}; +use crate::*; + +const BEH: &str = "\u{0628}"; +const TEH: &str = "\u{062A}"; +const NOON: &str = "\u{0646}"; + +fn db() -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + db.add_font(joining_alternates_font().expect("the test font builds")); + db +} + +fn style(family: &str, features: &[&str]) -> CharStyle { + CharStyle { font_family: family.into(), size: 100.0, features: features.iter().map(|f| f.to_string()).collect(), ..CharStyle::default() } +} + +/// `text` as one text object in `family`, each piece with its own features. +fn set(db: &FontDb, family: &str, pieces: &[(&str, &[&str])]) -> Vec { + let mut text = TextObject::point(Point::ZERO, "", style(family, &[])); + text.runs = pieces.iter().map(|(t, features)| TextRun { text: t.to_string(), style: style(family, features), inline: None }).collect(); + // In reading order of the characters, whichever way the line runs. + let mut glyphs = layout(db, &text).glyphs; + glyphs.sort_by_key(|g| g.byte); + glyphs +} + +fn joined(db: &FontDb, pieces: &[(&str, &[&str])]) -> Vec { + set(db, JOINING_ALTERNATES_FAMILY, pieces) +} + +fn gids(glyphs: &[PositionedGlyph]) -> Vec { + glyphs.iter().map(|g| g.gid).collect() +} + +fn g(c: char) -> u32 { + u32::from(source_sans_gid(c).expect("a Source Sans glyph")) +} + +/// The gap between each glyph and the next along the line, whichever way the line runs: 0 when the +/// letters touch. +fn gaps(glyphs: &[PositionedGlyph]) -> Vec { + // `origin` is the pen position at the glyph's left edge, also in right-to-left text. + let mut spans: Vec<(f64, f64)> = glyphs.iter().map(|g| (g.origin.x, g.origin.x + g.advance)).collect(); + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + spans.windows(2).map(|w| w[1].0 - w[0].1).collect() +} + +/// Beh, teh, noon: initial, medial and final in one word, teh drawn with `features`. +fn word(db: &FontDb, teh_features: &[&str]) -> Vec { + joined(db, &[(BEH, &[]), (TEH, teh_features), (NOON, &[])]) +} + +/// Without a feature the word is beh initial, teh medial, noon final. +#[test] +fn the_fixture_word_takes_its_joining_forms() { + let db = db(); + assert_eq!(gids(&joined(&db, &[(&format!("{BEH}{TEH}{NOON}"), &[])])), [g('B'), g('u'), g('M')]); + assert_eq!(JOINING_LETTERS.len(), 4); +} + +/// `salt=N` on the middle letter draws the font's Nth alternate for it, its neighbours keep their +/// initial and final forms (they are not shaped as lone letters), and the letters still touch. +#[test] +fn a_salt_value_on_one_letter_picks_its_alternate_and_keeps_the_word_joined() { + let db = db(); + let plain = word(&db, &[]); + assert_eq!(gids(&plain), [g('B'), g('u'), g('M')]); + let plain_gaps = gaps(&plain); + assert!(plain_gaps.iter().all(|gap| gap.abs() < 1e-6), "the word's glyphs touch: {plain_gaps:?}"); + for (features, middle) in [(&["salt"][..], 'w'), (&["salt=1"][..], 'w'), (&["salt=2"][..], 'x'), (&["salt=3"][..], 'y')] { + let glyphs = word(&db, features); + assert_eq!(gids(&glyphs), [g('B'), g(middle), g('M')], "{features:?}: the alternate between the word's own initial and final forms"); + let gap = gaps(&glyphs); + assert!(gap.iter().all(|gap| gap.abs() < 1e-6), "{features:?}: the letters touch: {gap:?}"); + } + // An alternate number the font has no glyph for, and the feature off, draw the default glyph. + for features in [&["salt=4"][..], &["-salt"][..], &["salt=0"][..], &["salt=2", "-salt"][..], &["ss03"][..]] { + assert_eq!(gids(&word(&db, features)), [g('B'), g('u'), g('M')], "{features:?}"); + } + // The last value of a feature wins. + assert_eq!(gids(&word(&db, &["salt=3", "salt=2"]))[1], g('x')); + // The font's salt lookup comes before its ss01 lookup, so salt takes the glyph first. + assert_eq!(gids(&word(&db, &["salt=2", "ss01"]))[1], g('x')); +} + +/// A feature on the first or the last letter of the word does not split it either. +#[test] +fn a_feature_on_an_end_letter_does_not_break_the_joining() { + let db = db(); + for (pieces, expected) in [ + (vec![(BEH, &["salt=2"][..]), (TEH, &[][..]), (NOON, &[][..])], [g('B'), g('u'), g('M')]), + (vec![(BEH, &[][..]), (TEH, &[][..]), (NOON, &["salt=2"][..])], [g('B'), g('u'), g('M')]), + (vec![(BEH, &["ss01"][..]), (TEH, &["ss01"][..]), (NOON, &["ss01"][..])], [g('B'), g('w'), g('M')]), + ] { + let glyphs = joined(&db, &pieces); + assert_eq!(gids(&glyphs), expected, "{pieces:?}"); + assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-6), "{pieces:?}"); + } +} + +/// The alternates of a letter depend on the form it takes where it stands: the final teh has two, +/// the medial teh three, so `salt=3` is an alternate of one and nothing for the other. +#[test] +fn the_alternate_a_value_picks_depends_on_the_letters_position() { + let db = db(); + let medial = |features: &[&str]| gids(&word(&db, features))[1]; + let final_form = |features: &[&str]| gids(&joined(&db, &[(BEH, &[]), (TEH, features)]))[1]; + assert_eq!((medial(&["salt=3"]), final_form(&["salt=3"])), (g('y'), g('U')), "no third alternate for the final form"); + assert_eq!((medial(&["salt=2"]), final_form(&["salt=2"])), (g('x'), g('X'))); + // A letter alone takes the isolated form's alternate. + assert_eq!(gids(&joined(&db, &[(TEH, &["salt"])])), [g('E')]); + assert_eq!(gids(&joined(&db, &[(TEH, &[])])), [g('t')]); +} + +/// A run with its own feature next to runs of another style in the word shapes with them: the +/// same glyph ids and places as a word typed in one run, when the feature changes nothing. +#[test] +fn runs_that_differ_only_in_features_shape_as_one_word() { + let db = db(); + let one_run = joined(&db, &[(&format!("{BEH}{TEH}{NOON}"), &[])]); + let three_runs = joined(&db, &[(BEH, &[]), (TEH, &["salt=9"]), (NOON, &["-liga"])]); + assert_eq!(gids(&three_runs), gids(&one_run)); + let (a, b): (Vec, Vec) = (one_run.iter().map(|g| g.origin.x).collect(), three_runs.iter().map(|g| g.origin.x).collect()); + assert_eq!(a, b, "the same places"); +} + +/// Where the owner's Kufi Murabba PIIIX is installed. +const KUFI_FILE: &str = "Library/Fonts/KufiMurabbaPIIIX-Regular.ttf"; + +/// The owner's Heavy weight of it, the one a final yeh was reported against. +const KUFI_HEAVY_FILE: &str = "Library/Fonts/KufiMurabbaPIIIX-Heavy.ttf"; + +/// The Kufi Murabba PIIIX of the font's author in a font database of its own, `None` when it is not +/// installed (the test then has nothing to read). When the file is there, a face that does not +/// load is a failure, never a skip. +fn kufi() -> Option<(FontDb, std::sync::Arc)> { + kufi_weight(KUFI_FILE, "Regular") +} + +/// [`kufi`] for the installed file `file` (under the home folder), whose face has style `style`. +fn kufi_weight(file: &str, style: &str) -> Option<(FontDb, std::sync::Arc)> { + let path = std::path::Path::new(&std::env::var_os("HOME")?).join(file); + 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())); + let db = FontDb::with_font_dirs(vec![]); + let added = db.add_font(bytes); + assert!(added > 0, "{} exists but no face of it loaded", path.display()); + let face = db.face("Kufi Murabba PIIIX", style).expect("a face"); + assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", style), "the installed font's face, not a fallback"); + Some((db, face)) +} + +/// The word al-Khwarizmi: alef, lam, khah, waw, alef, reh, zain, meem, farsi yeh. The lam, khah and +/// waw join (initial, medial, final); the letters around them do not join forward. +const KHWARIZMI: &str = "الخوارزمی"; +/// The medial khah, the middle letter of the joined lam-khah-waw. +const KHAH: usize = 2; + +/// The word with `features` on the character `at` only, shaped as one piece (the runs differ only +/// in features), in reading order of the characters. +fn khwarizmi(db: &FontDb, family: &str, at: usize, features: &[&str]) -> Vec { + let letters: Vec = KHWARIZMI.chars().collect(); + let part = |r: std::ops::Range| -> String { letters[r].iter().collect() }; + let (before, middle, after) = (part(0..at), part(at..at + 1), part(at + 1..letters.len())); + set(db, family, &[(&before, &[]), (&middle, features), (&after, &[])]) +} + +/// The gap between the drawn outlines of the letters that join (the lam, khah and waw), along the +/// line: 0 when the ink of one reaches the ink of the next. Unlike [`gaps`], which only checks that +/// the pen advances by each glyph's advance, this reads the glyphs drawn. +fn ink_gaps(glyphs: &[PositionedGlyph]) -> Vec { + use kurbo::Shape; + let mut spans: Vec<(f64, f64)> = glyphs[KHAH - 1..=KHAH + 1].iter().map(|g| (g.outline.bounding_box().x0, g.outline.bounding_box().x1)).collect(); + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + spans.windows(2).map(|w| w[1].0 - w[0].1).collect() +} + +/// One line of what the shaper made, for a failure message and `--nocapture`. +fn describe(glyphs: &[PositionedGlyph]) -> String { + glyphs.iter().map(|g| format!("{}@{:.1}+{:.1}", g.gid, g.origin.x, g.advance)).collect::>().join(" ") +} + +/// The Kufi Murabba PIIIX of the font's author, when it is installed: the medial khah of a word +/// takes each of its `salt` alternates, every other letter keeps the glyph it has without one and +/// the letters still touch. +#[test] +fn kufi_murabba_alternates_keep_the_word_joined() { + let Some((db, face)) = kufi() else { return }; + let family = face.family.clone(); + let plain = khwarizmi(&db, &family, KHAH, &[]); + assert_eq!(plain.len(), KHWARIZMI.chars().count(), "one glyph for each letter: {}", describe(&plain)); + assert!(gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the word joins without a gap: {:?}", gaps(&plain)); + assert!(ink_gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the drawn letters touch: {:?}", ink_gaps(&plain)); + let default = plain[KHAH].gid; + assert_eq!(face.glyph_name(default).as_deref(), Some("khah.medi"), "the khah in the middle of the word takes its medial form"); + let salt = + face.substitutions(default).into_iter().find(|s| &s.feature == b"salt" && s.glyphs.len() >= 2).expect("the medial khah has salt alternates"); + for (i, alternate) in salt.glyphs.iter().enumerate() { + let value = format!("salt={}", i + 1); + let glyphs = khwarizmi(&db, &family, KHAH, &[value.as_str()]); + assert_eq!(glyphs.len(), plain.len(), "{value}: {}", describe(&glyphs)); + assert_eq!(glyphs[KHAH].gid, *alternate, "{value}: the alternate"); + assert_ne!(glyphs[KHAH].gid, default, "{value}: not the default glyph"); + assert_eq!(face.glyph_name(*alternate), Some(format!("khah.medi.alt{}", i + 1)), "{value}: an alternate of the medial form"); + for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != KHAH) { + assert_eq!(a.gid, b.gid, "{value}: the letter {n} keeps its form: {}", describe(&glyphs)); + } + assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: no gap: {:?}", gaps(&glyphs)); + assert!(ink_gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: the drawn letters touch: {:?}", ink_gaps(&glyphs)); + } +} + +/// The stylistic sets `ss01` to `ss05` on a letter in the middle of a joined group: the glyph the +/// font's set gives its medial form (the default when it has none), the same forms for the others, +/// no gap. +#[test] +fn kufi_murabba_stylistic_sets_on_a_middle_letter_keep_the_word_joined() { + let Some((db, face)) = kufi() else { return }; + let family = face.family.clone(); + let plain = khwarizmi(&db, &family, KHAH, &[]); + let default = plain[KHAH].gid; + let mut changed = 0; + for set_number in 1..=5 { + let tag = format!("ss{set_number:02}"); + let wanted = face.substitutions(default).into_iter().find(|s| s.feature == tag.as_bytes() && !s.glyphs.is_empty()).map(|s| s.glyphs[0]); + let glyphs = khwarizmi(&db, &family, KHAH, &[tag.as_str()]); + assert_eq!(glyphs.len(), plain.len(), "{tag}: {}", describe(&glyphs)); + assert_eq!(glyphs[KHAH].gid, wanted.unwrap_or(default), "{tag}: the set's glyph for the medial khah"); + changed += usize::from(wanted.is_some_and(|w| w != default)); + for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != KHAH) { + assert_eq!(a.gid, b.gid, "{tag}: the letter {n} keeps its form: {}", describe(&glyphs)); + } + assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{tag}: no gap: {:?}", gaps(&glyphs)); + assert!(ink_gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{tag}: the drawn letters touch: {:?}", ink_gaps(&glyphs)); + } + assert!(changed >= 1, "the font has stylistic sets for the medial khah"); +} + +/// The `GSUB` reader gives the alternate set of each glyph (position in the set = the feature's +/// value), single substitutions, the feature that reaches a glyph, names and the glyph count. +#[test] +fn the_gsub_reader_lists_alternate_sets_singles_and_the_feature_reaching_a_glyph() { + use crate::{Substitution, SubstitutionKind}; + let db = db(); + let face = db.face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face"); + let salt = Substitution { feature: *b"salt", kind: SubstitutionKind::Alternate, glyphs: vec![g('w'), g('x'), g('y')] }; + let ss01 = Substitution { feature: *b"ss01", kind: SubstitutionKind::Single, glyphs: vec![g('w')] }; + assert_eq!(face.substitutions(g('u')), [salt.clone(), ss01.clone()], "salt (an alternate set) before ss01 (a single)"); + assert_eq!(salt.value_for(g('x')), Some(2)); + assert_eq!(salt.value_for(g('u')), None); + assert_eq!((ss01.value_for(g('w')), ss01.value_for(g('x'))), (Some(1), None)); + // Each form of a letter has its own alternates. + let sets = |c| face.substitutions(g(c)).iter().map(|s| (s.feature, s.glyphs.len())).collect::>(); + assert_eq!(sets('U'), [(*b"salt", 2), (*b"ss01", 1)]); + assert_eq!(sets('t'), [(*b"salt", 1), (*b"ss01", 1)]); + assert!(sets('B').is_empty() && sets('M').is_empty(), "the initial and final forms of the others have none"); + assert!(face.substitutions(face.glyph_count()).is_empty(), "past the glyphs"); + // The feature that reaches a glyph no character maps: the listed ones first, then positional forms. + assert_eq!(face.feature_reaching(g('x')), Some(*b"salt")); + assert_eq!(face.feature_reaching(g('w')), Some(*b"salt"), "salt ranks before ss01"); + assert_eq!(face.feature_reaching(g('B')), Some(*b"init")); + assert_eq!(face.feature_reaching(g('m')), Some(*b"medi")); + assert_eq!(face.feature_reaching(g('A')), Some(*b"fina")); + assert_eq!(face.feature_reaching(g('b')), None, "the isolated form is reached by no feature"); + // Names and the glyph count. + let bundled = db.face(FALLBACK_FAMILY, "Regular").expect("the bundled face"); + assert_eq!(face.glyph_count(), bundled.glyph_count()); + assert_eq!(face.glyph_name(g('B')).as_deref(), Some("B")); + assert_eq!(face.glyph_name(face.glyph_count()), None); + // `alternates` is the same reading, for a character. + let listed: Vec<([u8; 4], u32)> = face.alternates('\u{062A}').into_iter().map(|a| (a.feature, a.gid)).collect(); + assert_eq!(listed, [(*b"salt", g('E'))], "the isolated teh"); +} + +/// The alternates of a letter as it stands in its word: the glyph the shaper makes for it there, +/// its alternates, and the feature value that draws each on that letter alone. +#[test] +fn alternates_in_context_list_the_letters_positional_form_and_the_feature_that_draws_each() { + let db = db(); + let text = |teh: &[&str]| { + let mut t = TextObject::point(Point::ZERO, "", style(JOINING_ALTERNATES_FAMILY, &[])); + t.runs = [(BEH, &[][..]), (TEH, teh), (NOON, &[][..])] + .into_iter() + .map(|(s, f)| TextRun { text: s.to_string(), style: style(JOINING_ALTERNATES_FAMILY, f), inline: None }) + .collect(); + t + }; + let teh = BEH.len(); + let found = alternates_in_context(&db, &text(&[]), teh).expect("the medial teh"); + assert_eq!((found.default, found.current), (g('u'), g('u'))); + let listed: Vec<(u32, Option)> = found.alternates.iter().map(|a| (a.gid, a.apply.clone())).collect(); + assert_eq!( + listed, + [(g('w'), Some("salt".to_string())), (g('x'), Some("salt=2".to_string())), (g('y'), Some("salt=3".to_string()))], + "the medial form's three alternates, not the isolated form's one" + ); + // Drawn with salt=2 now: the default is still the form without it. + let now = alternates_in_context(&db, &text(&["salt=2"]), teh).expect("the medial teh"); + assert_eq!((now.default, now.current, now.alternates.len()), (g('u'), g('x'), 3)); + // The first and last letters take their own forms. + let beh = alternates_in_context(&db, &text(&[]), 0).expect("the initial beh"); + assert_eq!((beh.default, beh.alternates.len()), (g('B'), 0)); + let noon = alternates_in_context(&db, &text(&[]), teh + TEH.len()).expect("the final noon"); + assert_eq!((noon.default, noon.alternates.len()), (g('M'), 0)); + assert_eq!(alternates_in_context(&db, &text(&[]), 1), None, "not a character boundary"); + // alternate_changes: the character keeps only the alternate feature it is given. + let own = |f: &[&str]| f.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(alternate_changes(&own(&["ss01", "salt=2", "dlig", "-ss02"]), Some("salt=3")), ["-ss01", "salt=3"]); + assert_eq!(alternate_changes(&own(&["ss01", "salt=2", "dlig"]), None), ["-ss01", "-salt"]); + assert!(alternate_changes(&own(&["salt=2", "dlig"]), Some("salt=2")).is_empty()); + assert_eq!(alternate_changes(&own(&["salt"]), Some("salt=2")), ["salt=2"]); + assert!(alternate_changes(&own(&["dlig"]), None).is_empty()); + assert!(is_alternate_feature("-ss03") && is_alternate_feature("salt=2") && !is_alternate_feature("dlig") && !is_alternate_feature("titl")); +} + +/// A glyph no feature of the style draws is listed without one, and a feature's glyph is found +/// with the feature that draws it. +#[test] +fn a_variant_no_feature_draws_has_no_feature_to_apply() { + use crate::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, u2, u3, u4] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let st = CharStyle { font_family: alternates_family(true).into(), size: 100.0, ..CharStyle::default() }; + let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", st), 1).expect("the a"); + let apply = |gid: u32| found.alternates.iter().find(|a| a.gid == gid).map(|a| a.apply.clone()); + assert_eq!(found.default, face.glyph_for('a')); + assert_eq!(apply(u1), Some(Some("swsh=2".to_string())), "swsh before aalt"); + assert_eq!(apply(u2), Some(Some("ss01".to_string()))); + assert_eq!(apply(u3), Some(Some("swsh".to_string()))); + assert_eq!(apply(u4), Some(Some("ss02".to_string()))); + assert_eq!(apply(face.glyph_for('c')), Some(None), "cv01 has no style tag"); + assert!(face.unencoded_glyphs().contains(&u1) && !face.unencoded_glyphs().contains(&face.glyph_for('a'))); +} + +/// The alternates are listed `aalt` first, then `salt`, the stylistic sets, `swsh` and `cv01`, each +/// glyph once under the first feature that lists it, whichever feature draws it in the text. +#[test] +fn alternates_are_listed_aalt_first_under_the_first_feature_that_lists_them() { + use crate::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, u2, u3, u4] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let st = CharStyle { font_family: alternates_family(true).into(), size: 100.0, ..CharStyle::default() }; + let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", st), 1).expect("the a"); + let listed: Vec<(u32, [u8; 4], Option)> = found.alternates.iter().map(|a| (a.gid, a.feature, a.apply.clone())).collect(); + let (a, b, c) = (face.glyph_for('a'), face.glyph_for('b'), face.glyph_for('c')); + assert_eq!( + listed, + [ + (a, *b"aalt", Some("aalt".to_string())), + (u1, *b"aalt", Some("swsh=2".to_string())), + (b, *b"aalt", Some("salt".to_string())), + (u2, *b"ss01", Some("ss01".to_string())), + (u4, *b"ss02", Some("ss02".to_string())), + (u3, *b"swsh", Some("swsh".to_string())), + (c, *b"cv01", None), + ], + "aalt's set first, with the default glyph it lists; b is aalt's though salt draws it" + ); +} + +/// A glyph that more than its character makes (the lam-alef ligature) has no alternates of its own +/// there, though the font substitutes it (`salt`). +#[test] +fn a_ligature_has_no_alternates_in_context() { + use crate::test_fonts::{LAM_ALEF_SALT, lam_alef_family, lam_alef_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(lam_alef_font(b"rlig").expect("the test font builds")); + let family = lam_alef_family(b"rlig"); + let face = db.face(family, "Regular").expect("the test face"); + let lam_alef = TextObject::point(Point::ZERO, "\u{0644}\u{0627}", style(family, &[])); + let ligature = g('H'); + assert_eq!( + face.substitutions(ligature).iter().map(|s| s.glyphs.clone()).collect::>(), + [vec![g(LAM_ALEF_SALT)]], + "the font gives the ligature an alternate" + ); + let found = alternates_in_context(&db, &lam_alef, 0).expect("the ligature"); + assert_eq!((found.default, found.current), (ligature, ligature)); + assert!(found.alternates.is_empty(), "{:?}", found.alternates); + // The lam alone is its own character: it has no alternates because the font gives it none. + let alone = alternates_in_context(&db, &TextObject::point(Point::ZERO, "\u{0644}", style(family, &[])), 0).expect("the lam"); + assert_eq!(alone.default, g('l')); +} + +/// A word is shaped as one piece (to 256 characters each side of the selected one), the selected +/// character found in it by its offset: a letter deep in a long word and the letters next to a +/// space, a line feed or a line separator each get the form their place in their own word gives. +#[test] +fn the_probe_finds_the_character_in_its_word_across_a_long_word_and_breaks() { + let db = db(); + let text = |s: &str| TextObject::point(Point::ZERO, s, style(JOINING_ALTERNATES_FAMILY, &[])); + let default_of = |t: &TextObject, byte: usize| { + let found = alternates_in_context(&db, t, byte).expect("a character"); + (found.default, found.alternates.len()) + }; + // 700 letters: beh, 698 teh, noon. Each is found at its own offset, 300 of them past the + // 256 characters either side that are shaped. + let long = text(&format!("{BEH}{}{NOON}", TEH.repeat(698))); + assert_eq!(default_of(&long, 0), (g('B'), 0), "the first letter is initial"); + assert_eq!(default_of(&long, 2 * 300), (g('u'), 3), "a medial teh deep in the word, with its alternates"); + assert_eq!(default_of(&long, 2 * 699), (g('M'), 0), "the last letter is final"); + // Two words: the break leaves each its own initial and final letters. The teh before the break + // ends its word (final) and the one after starts the next (initial), not medial. + for brk in [" ", "\n", "\u{2028}", "\u{2029}"] { + let two = text(&format!("{BEH}{TEH}{brk}{TEH}{NOON}")); + let after = 2 * 2 + brk.len(); + assert_eq!(default_of(&two, 2), (g('U'), 2), "{brk:?}: final teh before the break, with its two alternates"); + assert_eq!(default_of(&two, after), (g('T'), 0), "{brk:?}: initial teh after it"); + assert_eq!(default_of(&two, after + 2), (g('M'), 0), "{brk:?}: the noon after it is final"); + } +} + +/// A word of alternates longer than the candidates tried (512) leaves the features after it +/// untried: `aalt`'s 512 values use them all, so `salt`'s glyph is listed but no feature is applied +/// for it. +#[test] +fn the_candidates_tried_are_capped() { + use crate::test_fonts::{CAPPED_CANDIDATES_FAMILY, capped_candidates_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(capped_candidates_font().expect("the test font builds")); + let face = db.face(CAPPED_CANDIDATES_FAMILY, "Regular").expect("the test face"); + let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", style(CAPPED_CANDIDATES_FAMILY, &[])), 1).expect("the a"); + let listed: Vec<(u32, Option)> = found.alternates.iter().map(|a| (a.gid, a.apply.clone())).collect(); + assert_eq!(listed, [(face.glyph_for('b'), Some("aalt".to_string())), (face.glyph_for('c'), None)]); +} + +/// A feature on a one-byte character (Latin) reaches that character: the range of a feature is +/// its source bytes, and harfrust applies a range `a..b` to clusters `a..b - 1` unless the end is +/// inclusive, which would leave a one-byte character out. +#[test] +fn a_feature_on_a_one_byte_character_reaches_it() { + use crate::test_fonts::{alternates_family, alternates_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(alternates_font(true).expect("the test font builds")); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let st = |features: &[&str]| style(alternates_family(true), features); + let mut text = TextObject::point(Point::ZERO, "", st(&[])); + text.runs = vec![ + TextRun { text: "x".into(), style: st(&[]), inline: None }, + TextRun { text: "a".into(), style: st(&["salt"]), inline: None }, + TextRun { text: "y".into(), style: st(&[]), inline: None }, + ]; + let gids: Vec = layout(&db, &text).glyphs.iter().map(|g| g.gid).collect(); + assert_eq!(gids, [face.glyph_for('x'), face.glyph_for('b'), face.glyph_for('y')], "salt draws b for the a alone"); +} + +/// The owner's Kufi Murabba PIIIX Heavy, with the final letter of al-Khwarizmi as Persian yeh +/// (U+06CC), Arabic yeh (U+064A) or alef maksura (U+0649): in each the letter takes its own final +/// form, the byte offset of the last character finds its alternates, `salt` to `salt=6` draw them +/// on that letter alone with the rest of the word and its joining untouched, and the glyph an +/// alternate is (`yehfarsi.fina.alt2`) is traced back to its letter and its place in the word. +#[test] +fn kufi_murabba_heavy_final_letters_alternates_belong_to_their_letter() { + let Some((db, face)) = kufi_weight(KUFI_HEAVY_FILE, "Heavy") else { return }; + let family = face.family.clone(); + for (letter, base) in [('\u{06CC}', "yehfarsi"), ('\u{064A}', "yeh"), ('\u{0649}', "alefmaksura")] { + let head: String = KHWARIZMI.chars().take(KHWARIZMI.chars().count() - 1).collect(); + let word = format!("{head}{letter}"); + let last = word.len() - letter.len_utf8(); + let letter = letter.to_string(); + let plain = set(&db, &family, &[(&head, &[]), (&letter, &[])]); + assert_eq!(plain.len(), KHWARIZMI.chars().count(), "{base}: one glyph for each letter: {}", describe(&plain)); + assert_eq!(face.glyph_name(plain[plain.len() - 1].gid), Some(format!("{base}.fina")), "{base}: the letter ends the word in its final form"); + let mut text = TextObject::point(Point::ZERO, "", style(&family, &[])); + text.runs = vec![TextRun { text: word.clone(), style: style(&family, &[]), inline: None }]; + let found = alternates_in_context(&db, &text, last).expect("the final letter"); + assert_eq!((found.default, found.current), (plain[plain.len() - 1].gid, plain[plain.len() - 1].gid), "{base}"); + assert_eq!(found.alternates.len(), 6, "{base}: the six alternates of the final form"); + for (n, a) in found.alternates.iter().enumerate() { + let tag = if n == 0 { "salt".to_string() } else { format!("salt={}", n + 1) }; + assert_eq!(a.apply.as_deref(), Some(tag.as_str()), "{base}: the feature that draws alternate {}", n + 1); + assert_eq!(face.glyph_name(a.gid), Some(format!("{base}.fina.alt{}", n + 1)), "{base}"); + let glyphs = set(&db, &family, &[(&head, &[]), (&letter, &[tag.as_str()])]); + assert_eq!(glyphs.len(), plain.len(), "{base} {tag}: {}", describe(&glyphs)); + assert_eq!(glyphs[glyphs.len() - 1].gid, a.gid, "{base} {tag}: the alternate"); + assert!( + glyphs.iter().zip(&plain).take(plain.len() - 1).all(|(x, y)| x.gid == y.gid), + "{base} {tag}: the rest of the word keeps its forms" + ); + let origin = face.glyph_origin(a.gid).expect("the alternate comes from a letter"); + assert_eq!(origin.ch.to_string(), letter, "{base}: {} is a form of its own letter", face.glyph_name(a.gid).unwrap_or_default()); + assert_eq!(origin.position(), Some(JoiningPosition::Final), "{base}: and of its end of a word"); + } + } + // Glyph 820 of the owner's file is yehfarsi.fina.alt2: Persian yeh's, at the end of a word. + let g820 = face.glyph_origin(820).expect("glyph 820 comes from a letter"); + assert_eq!((face.glyph_name(820).as_deref(), g820.ch, g820.position()), (Some("yehfarsi.fina.alt2"), '\u{06CC}', Some(JoiningPosition::Final))); +} diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index 9bb7700d66..b50b39da83 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -152,13 +152,13 @@ fn paint_only_differences_are_told_apart() { assert_eq!(harakat_ranges("اللَّهِ", 0.."اللَّهِ".len()), [6..10, 12..14], "shadda and fatha together"); } -/// The allocation-free comparison must keep the previous comparison's complete contract, -/// including attributes that affect layout rather than shaping and non-reflexive file values. +/// Compare every drawing attribute without allocations, retaining PartialEq for non-reflexive +/// values while allowing features already resolved by shaping. #[test] -fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { +fn paint_only_keeps_every_drawing_attribute_and_partial_equality() { use vectorcraft_doc::{CharAlign, CharPosition, Dash, Digits, FontHeightReference, GlyphChoice, LineCap, LineJoin, ParaDirection, StrokeAlign}; - fn previous(a: &CharStyle, b: &CharStyle) -> bool { + fn normalized(a: &CharStyle, b: &CharStyle) -> bool { let mut b = b.clone(); b.fill = a.fill.clone(); b.stroke = a.stroke.clone(); @@ -172,12 +172,13 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { b.stroke_align = a.stroke_align; b.style_name = a.style_name.clone(); b.kerning_before = a.kerning_before; + b.features = a.features.clone(); *a == b } fn check(a: &CharStyle, b: &CharStyle, want: bool, name: &str) { for (left, right) in [(a, b), (b, a)] { - assert_eq!(paint_only(left, right), previous(left, right), "{name}: old contract"); + assert_eq!(paint_only(left, right), normalized(left, right), "{name}: normalized contract"); } assert_eq!(paint_only(a, b), want, "{name}"); } @@ -213,7 +214,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { field!(underline, true, false); field!(strikethrough, true, false); field!(all_caps, true, false); - field!(features, vec!["-liga".into()], false); + field!(features, vec!["-liga".into()], true); field!(position, CharPosition::Superscript(Default::default()), false); field!(small_caps, Some(80.0), false); field!(char_align, CharAlign::EmBoxTop, false); @@ -294,6 +295,42 @@ fn synthetic_harakat_keep_geometry_with_and_without_gdef_marks() { } } +#[test] +fn feature_only_harakat_boundaries_keep_attachment_and_ownership() { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(crate::test_fonts::lam_alef_font(b"rlig").expect("the test font builds")), 1); + let family = crate::test_fonts::lam_alef_family(b"rlig"); + let face = db.face(family, "Regular").expect("the test face"); + const WORD: &str = "بَبَبَ"; + let plain = TextObject::point(Point::ZERO, WORD, style(family)); + let before = layout(&db, &plain); + assert_eq!(before.glyphs.len(), 6, "three bases with their attached marks"); + assert!(before.glyphs.iter().any(|g| is_gdef_mark(&face, g.gid))); + for feature_marks in [false, true] { + for coloured in [false, true] { + let mut text = plain.clone(); + for (byte, ch) in WORD.char_indices() { + crate::edit::style_range(&mut text.runs, byte, byte + ch.len_utf8(), |st| { + if is_harakat(ch) == feature_marks { + st.features = vec!["salt=2".into()]; + } + if coloured && is_harakat(ch) { + st.fill = red(); + } + }); + } + let after = layout(&db, &text); + assert_eq!(geometry(&after), geometry(&before), "features on marks {feature_marks}, coloured {coloured}"); + for glyph in &after.glyphs { + let mark = is_gdef_mark(&face, glyph.gid); + let owner = &run_of(&text, glyph).style; + assert_eq!(!owner.features.is_empty(), mark == feature_marks, "each glyph keeps its own character's features"); + assert_eq!(owner.fill == red(), coloured && mark, "only marks take the mark paint"); + } + } + } +} + /// A range is normalised as `edit::style_range` takes one: clamped, each end moved back to the /// start of its character, put in order. Never a silent empty result for a usable range. #[test] diff --git a/crates/text/src/tests/justify.rs b/crates/text/src/tests/justify.rs index 95646bc57b..dd80eed25b 100644 --- a/crates/text/src/tests/justify.rs +++ b/crates/text/src/tests/justify.rs @@ -130,6 +130,263 @@ fn a_justified_right_to_left_word_is_spread_between_its_drawn_letters() { assert!(gaps.iter().all(|&g| g > 1.0 && (g - gaps[0]).abs() < 1e-6), "{gaps:?}"); } +/// A word with a combining mark that has no precomposed form, `x` + U+0301: the base and the mark +/// are two glyphs of one shaped cluster at byte 1, between `a` (byte 0) and `z` (byte 4). +const MARKED: &str = "ax\u{301}z"; + +/// Where each glyph of the cluster at byte `b` sits from the cluster's first glyph. +fn cluster_offsets(l: &TextLayout, b: usize) -> Vec { + let glyphs: Vec<&PositionedGlyph> = l.glyphs.iter().filter(|g| g.byte == b).collect(); + glyphs.iter().map(|g| g.origin - glyphs[0].origin).collect() +} + +fn assert_same_offsets(a: &[Vec2], b: &[Vec2], what: &str) { + assert!(a.len() == b.len() && a.iter().zip(b).all(|(p, q)| (*p - *q).hypot() < 1e-6), "{what}: {a:?} vs {b:?}"); +} + +/// The precondition of the cluster tests: `MARKED` shapes the base and its mark as two glyphs +/// with one byte. +fn marked_cluster(l: &TextLayout) -> Vec { + let offsets = cluster_offsets(l, 1); + assert_eq!(offsets.len(), 2, "x and its mark are one cluster: {:?}", l.glyphs); + offsets +} + +/// The space Full Justify put between two clusters, from `a`'s first glyph to `b`'s, beyond the +/// distance the unspaced `natural` layout has between them. +fn added_gap(l: &TextLayout, natural: &TextLayout, a: usize, b: usize) -> f64 { + let d = |l: &TextLayout| (at(l, b).origin - at(l, a).origin).hypot(); + d(l) - d(natural) +} + +/// Full Justify spreads a single word between its clusters, never between a letter and its +/// combining mark: the mark keeps its place on its letter, the two gaps are equal, and the word +/// still reaches the measure. +#[test] +fn single_word_full_justify_keeps_combining_mark_with_base() { + let natural = layout(db(), &point(MARKED, JustificationOptions::default())); + let unspaced = marked_cluster(&natural); + let l = layout(db(), &area(MARKED, 300.0, Justify::JustifyAll, JustificationOptions::default())); + assert_same_offsets(&cluster_offsets(&l, 1), &unspaced, "the mark stays on its letter"); + let (first, second) = (added_gap(&l, &natural, 0, 1), added_gap(&l, &natural, 1, 4)); + assert!(first > 1.0 && (first - second).abs() < 1e-6, "{first} {second}"); + let z = at(&l, 4); + assert!((z.origin.x + at(&natural, 4).advance - 300.0).abs() < 1e-6, "{:?}", z.origin); +} + +/// Desired Letter Spacing on a ragged line, and the letter spacing range of a justified one, go +/// after each cluster, not between a letter and its mark. +#[test] +fn desired_and_fitted_letter_spacing_skip_shaped_cluster() { + let natural = layout(db(), &point(MARKED, JustificationOptions::default())); + let unspaced = marked_cluster(&natural); + + let desired = layout(db(), &point(MARKED, JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() })); + assert_same_offsets(&cluster_offsets(&desired, 1), &unspaced, "Desired Letter Spacing"); + let gap = at(&desired, 0).advance - at(&natural, 0).advance; + assert!(gap > 1.0, "{gap}"); + assert!((added_gap(&desired, &natural, 1, 4) - gap).abs() < 1e-6, "one gap after the cluster"); + + // 4 pt of room, less than the two gaps' Maximum (half a space each): letter spacing alone. + let w = natural.lines[0].x1 - natural.lines[0].x0 + 4.0; + let range = JustificationOptions { letter_spacing: [0.0, 0.0, 50.0], ..JustificationOptions::default() }; + let fitted = layout(db(), &area(MARKED, w, Justify::JustifyAll, range)); + assert_same_offsets(&cluster_offsets(&fitted, 1), &unspaced, "fitted letter spacing"); + let (first, second) = (added_gap(&fitted, &natural, 0, 1), added_gap(&fitted, &natural, 1, 4)); + assert!((first - 2.0).abs() < 1e-6 && (second - 2.0).abs() < 1e-6, "{first} {second}"); +} + +/// Right to left (a bidi override), Full Justify puts its gaps between the clusters as they are +/// drawn and leaves the mark on its letter. +#[test] +fn rtl_single_word_cluster_gap_follows_drawn_order() { + // RLO, then MARKED, then PDF: a at byte 3, the cluster at byte 4, z at byte 7. + let text = "\u{202E}ax\u{301}z\u{202C}"; + let natural = layout(db(), &point(text, JustificationOptions::default())); + let unspaced = cluster_offsets(&natural, 4); + assert_eq!(unspaced.len(), 2, "x and its mark are one cluster: {:?}", natural.glyphs); + assert!(at(&natural, 4).rtl, "the override sets the word right to left"); + let l = layout(db(), &area(text, 300.0, Justify::JustifyAll, JustificationOptions::default())); + assert_same_offsets(&cluster_offsets(&l, 4), &unspaced, "the mark stays on its letter"); + // Drawn from left to right: z, the cluster, a. + assert!(at(&l, 7).origin.x < at(&l, 4).origin.x && at(&l, 4).origin.x < at(&l, 3).origin.x); + let (first, second) = (added_gap(&l, &natural, 7, 4), added_gap(&l, &natural, 4, 3)); + assert!(first > 1.0 && (first - second).abs() < 1e-6, "{first} {second}"); + // The zero-width override marks take no gap: the word runs from edge to edge. + assert!(at(&l, 7).origin.x.abs() < 1e-6, "{:?}", at(&l, 7).origin); + assert!((at(&l, 3).origin.x + at(&natural, 3).advance - 300.0).abs() < 1e-6, "{:?}", at(&l, 3).origin); +} + +/// Desired Letter Spacing goes between the letters of a word wrapped in bidi override marks, +/// not after the zero-width marks or before the closing one. +#[test] +fn desired_letter_spacing_skips_bidi_format_controls() { + let text = "\u{202E}ax\u{301}z\u{202C}"; + let natural = layout(db(), &point(text, JustificationOptions::default())); + let spaced = layout(db(), &point(text, JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() })); + let gap = at(&spaced, 3).advance - at(&natural, 3).advance; + assert!(gap > 1.0, "{gap}"); + assert!((at(&spaced, 7).origin.x - at(&natural, 7).origin.x).abs() < 1e-6, "{:?}", at(&spaced, 7).origin); + let extent = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0; + assert!((extent(&spaced) - extent(&natural) - 2.0 * gap).abs() < 1e-6, "{} {} {gap}", extent(&spaced), extent(&natural)); +} + +/// A zero-width joiner at the start and end of a word, and a word joiner and a soft hyphen inside +/// it, take no letter spacing and no Full Justify gap: the space goes between a, b and c only. +#[test] +fn letter_spacing_and_full_justify_skip_joiners_and_soft_hyphens() { + // ZWJ (bytes 0..3), a (3), word joiner (4..7), b (7), soft hyphen (8..10), c (10), ZWJ (11..14). + let text = "\u{200D}a\u{2060}b\u{AD}c\u{200D}"; + let natural = layout(db(), &point(text, JustificationOptions::default())); + + let spaced = layout(db(), &point(text, JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() })); + let gap = at(&spaced, 3).advance - at(&natural, 3).advance; + assert!(gap > 1.0, "{gap}"); + assert!((at(&spaced, 3).origin.x - at(&natural, 3).origin.x).abs() < 1e-6, "{:?}", at(&spaced, 3).origin); + assert!((added_gap(&spaced, &natural, 7, 10) - gap).abs() < 1e-6, "one gap between b and c"); + let extent = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0; + assert!((extent(&spaced) - extent(&natural) - 2.0 * gap).abs() < 1e-6, "{} {} {gap}", extent(&spaced), extent(&natural)); + + let l = layout(db(), &area(text, 300.0, Justify::JustifyAll, JustificationOptions::default())); + let (first, second) = (added_gap(&l, &natural, 3, 7), added_gap(&l, &natural, 7, 10)); + assert!(first > 1.0 && (first - second).abs() < 1e-6, "{first} {second}"); + assert!(at(&l, 3).origin.x.abs() < 1e-6, "{:?}", at(&l, 3).origin); + assert!((at(&l, 10).origin.x + at(&natural, 10).advance - 300.0).abs() < 1e-6, "{:?}", at(&l, 10).origin); +} + +/// Every assigned default-ignorable character drawn with no width is a format control, the +/// zero-width space included; word spaces, the Hangul fillers and ordinary letters are not. +#[test] +fn format_controls_are_the_zero_width_default_ignorables() { + use crate::justify::is_format_control; + let controls = "\u{AD}\u{34F}\u{61C}\u{180E}\u{200B}\u{200C}\u{200D}\u{200E}\u{202E}\u{2060}\u{2065}\u{2066}\u{206F}"; + let more = "\u{FE0F}\u{FEFF}\u{1BCA0}\u{1D173}\u{E0001}\u{E007F}\u{E0100}"; + for ch in controls.chars().chain(more.chars()) { + assert!(is_format_control(ch), "U+{:04X}", ch as u32); + } + for ch in "a \u{2009}\u{3000}\u{2028}\u{3164}\u{FFA0}\u{301}".chars() { + assert!(!is_format_control(ch), "U+{:04X}", ch as u32); + } +} + +/// A zero-width space is a line break opportunity but not a word separator: a justified line +/// gives it no share of its room, Full Justify spreads a word with one between its visible letters, +/// and Desired Letter Spacing goes between the letters on either side of it. +#[test] +fn zero_width_space_takes_no_word_space_room() { + // a (0), b (1), zero-width space (2..5), c (5), d (6), space (7), e (8), f (9). + let natural = layout(db(), &point("ab\u{200B}cd ef", JustificationOptions::default())); + let l = layout(db(), &area("ab\u{200B}cd ef", 300.0, Justify::JustifyAll, JustificationOptions::default())); + assert!(added_gap(&l, &natural, 1, 5).abs() < 1e-6, "b and c stay together: {}", added_gap(&l, &natural, 1, 5)); + assert!((at(&l, 9).origin.x + at(&natural, 9).advance - 300.0).abs() < 1e-6, "the word space takes the room: {:?}", at(&l, 9).origin); + + // No word space: the zero-width space does not stand in for one. + let text = "ab\u{200B}cd"; + let natural = layout(db(), &point(text, JustificationOptions::default())); + let l = layout(db(), &area(text, 300.0, Justify::JustifyAll, JustificationOptions::default())); + let gaps = [added_gap(&l, &natural, 0, 1), added_gap(&l, &natural, 1, 5), added_gap(&l, &natural, 5, 6)]; + assert!(gaps[0] > 1.0 && gaps.iter().all(|g| (g - gaps[0]).abs() < 1e-6), "{gaps:?}"); + assert!((at(&l, 6).origin.x + at(&natural, 6).advance - 300.0).abs() < 1e-6, "{:?}", at(&l, 6).origin); + + let spaced = layout(db(), &point(text, JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() })); + let gap = at(&spaced, 0).advance - at(&natural, 0).advance; + assert!(gap > 1.0 && (added_gap(&spaced, &natural, 1, 5) - gap).abs() < 1e-6, "{gap} {}", added_gap(&spaced, &natural, 1, 5)); + + // It is still a place to break the line. + let w = natural.lines[0].x1 - natural.lines[0].x0 - 5.0; + let wrapped = layout(db(), &area(text, w, Justify::Left, JustificationOptions::default())); + assert_eq!(wrapped.lines.len(), 2); + assert_eq!(wrapped.lines[0].end, 5, "breaks after the zero-width space"); +} + +/// Desired Letter Spacing puts the gap between b and c on the zero-width space, so a line that +/// breaks there ends at b's own advance and wraps where the unspaced text does. +#[test] +fn desired_letter_spacing_ends_a_line_broken_at_a_zero_width_space_without_a_gap() { + // a (0), b (1), zero-width space (2..5), c (5), d (6). + let text = "ab\u{200B}cd"; + let desired = JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() }; + let natural = layout(db(), &point(text, JustificationOptions::default())); + let spaced = layout(db(), &point(text, desired.clone())); + let gap = at(&spaced, 0).advance - at(&natural, 0).advance; + assert!(gap > 1.0, "{gap}"); + // Room for a, its gap and b, and half a gap more: not for a second gap after b. + let w = at(&natural, 0).advance + gap + at(&natural, 1).advance + 0.5 * gap; + let unspaced = layout(db(), &area(text, w, Justify::Left, JustificationOptions::default())); + assert_eq!(unspaced.lines.first().map(|l| l.end), Some(5), "the unspaced text breaks after the zero-width space"); + let wrapped = layout(db(), &area(text, w, Justify::Left, desired)); + assert_eq!(wrapped.lines.len(), 2, "{:?}", wrapped.lines); + assert_eq!(wrapped.lines[0].end, 5, "breaks after the zero-width space"); + assert!((at(&wrapped, 1).advance - at(&natural, 1).advance).abs() < 1e-6, "b keeps its own advance: {}", at(&wrapped, 1).advance); + let b = at(&wrapped, 1); + let end = b.origin.x + b.advance; + assert!((end - (at(&natural, 0).advance + gap + at(&natural, 1).advance)).abs() < 1e-6, "the line ends at b: {end}"); +} + +/// A single word under Full Justify that breaks at a soft hyphen spreads its first line between its +/// letters and the hyphen drawn after the last one, all with the same gap. +#[test] +fn single_word_full_justify_spreads_up_to_the_drawn_hyphen() { + // a (0), b (1), c (2), soft hyphen (3..5), then d at byte 5. + let text = "abc\u{AD}defghij"; + let hyphenated = layout(db(), &point("abc-", JustificationOptions::default())); + let w = hyphenated.lines[0].x1 - hyphenated.lines[0].x0 + 30.0; + // The same first line, set ragged: the distances justification adds to. + let natural = layout(db(), &area(text, w, Justify::Left, JustificationOptions::default())); + let l = layout(db(), &area(text, w, Justify::JustifyLeft, JustificationOptions::default())); + for layout in [&natural, &l] { + assert!(layout.lines.len() >= 2 && layout.lines[0].end == 5, "the first line ends at the soft hyphen: {:?}", layout.lines); + } + let hyphen = |l: &TextLayout| l.glyphs[l.lines[0].glyph_end - 1].clone(); + let ab = added_gap(&l, &natural, 0, 1); + let bc = added_gap(&l, &natural, 1, 2); + let c_hyphen = (hyphen(&l).origin.x - at(&l, 2).origin.x) - (hyphen(&natural).origin.x - at(&natural, 2).origin.x); + assert!(ab > 1.0 && (ab - bc).abs() < 1e-6 && (ab - c_hyphen).abs() < 1e-6, "{ab} {bc} {c_hyphen}"); + let end = hyphen(&l).origin.x + hyphen(&l).advance; + assert!((end - w).abs() < 1e-6, "the hyphen reaches the edge: {end} {w}"); +} + +/// In vertical type, Full Justify spreads a word down the column between its clusters and leaves +/// the mark on its letter. +#[test] +fn vertical_single_word_cluster_gap_follows_drawn_order() { + let vertical = |t: &mut TextObject| t.vertical = true; + let mut plain = point(MARKED, JustificationOptions::default()); + vertical(&mut plain); + let natural = layout(db(), &plain); + let unspaced = marked_cluster(&natural); + let mut t = area(MARKED, 100.0, Justify::JustifyAll, JustificationOptions::default()); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 300.0).to_path(0.1)) }; + vertical(&mut t); + let l = layout(db(), &t); + assert_same_offsets(&cluster_offsets(&l, 1), &unspaced, "the mark stays on its letter"); + assert!(at(&l, 0).origin.y < at(&l, 1).origin.y && at(&l, 1).origin.y < at(&l, 4).origin.y, "down the column"); + let (first, second) = (added_gap(&l, &natural, 0, 1), added_gap(&l, &natural, 1, 4)); + assert!(first > 1.0 && (first - second).abs() < 1e-6, "{first} {second}"); +} + +/// A Devanagari syllable whose vowel sign is drawn before its consonant (कि) is one cluster of two +/// glyphs: Full Justify keeps them together. Skipped without an installed Devanagari face. +#[test] +fn single_word_full_justify_keeps_conjunct_cluster() { + let Some(face) = super::devanagari::devanagari_test_face("single_word_full_justify_keeps_conjunct_cluster") else { return }; + // कि (bytes 0..6) and क (byte 6). + let text = "\u{915}\u{93F}\u{915}"; + let styled = |mut t: TextObject| { + for run in &mut t.runs { + run.style.font_family = face.family.clone(); + run.style.font_style = face.style.clone(); + } + t + }; + let natural = layout(db(), &styled(point(text, JustificationOptions::default()))); + let unspaced = cluster_offsets(&natural, 0); + assert!(unspaced.len() >= 2, "कि is one cluster of several glyphs: {:?}", natural.glyphs); + let l = layout(db(), &styled(area(text, 300.0, Justify::JustifyAll, JustificationOptions::default()))); + assert_same_offsets(&cluster_offsets(&l, 0), &unspaced, "the syllable stays together"); + assert!(added_gap(&l, &natural, 0, 6) > 1.0); +} + /// Single Word Justification: a justified line holding one word is spread between its letters /// (Full Justify, the default) or aligned as chosen. #[test] diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index a1e06887a7..d7d8334400 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -629,6 +629,66 @@ fn an_upright_ligature_takes_its_caret_down_its_cell() { assert!((at(900) - 0.5).abs() < 1e-9, "{}", at(900)); } +#[test] +fn feature_only_settings_keep_a_ligature_whole() { + let db = db_with(vertical_ligature_caret_font(280)); + for vertical in [false, true] { + let mut text = point("fi", VERTICAL_LIGATURE_CARET_FAMILY); + text.vertical = vertical; + let before = layout(&db, &text); + assert_eq!(before.glyphs.len(), 1, "the fixture ligates fi"); + let plain = &before.glyphs[0]; + for byte in [0, 1] { + for feature in ["salt=2", "ss03"] { + let mut featured = text.clone(); + crate::edit::style_range(&mut featured.runs, byte, byte + 1, |st| st.features = vec![feature.into()]); + let after = layout(&db, &featured); + assert_eq!(after.glyphs.len(), 1, "{feature} at {byte}, vertical {vertical}: an unchanged fi stays ligated"); + let glyph = &after.glyphs[0]; + assert_eq!((glyph.gid, glyph.byte, glyph.len), (plain.gid, 0, 2)); + assert_eq!(glyph.origin, plain.origin); + assert_eq!(glyph.xf, plain.xf); + assert_eq!(glyph.advance, plain.advance); + assert_eq!(glyph.carets, plain.carets); + } + } + } +} + +#[test] +fn drawing_differences_still_split_a_feature_styled_ligature() { + let db = db_with(ligature_caret_font(None)); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test face"); + let changes: [fn(&mut CharStyle); 3] = + [|st| st.fill = vectorcraft_doc::color::Paint::None, |st| st.baseline_shift = 10.0, |st| st.underline = true]; + for (i, change) in changes.into_iter().enumerate() { + for byte in [0, 1] { + let mut text = point("fi", LIGATURE_CARET_FAMILY); + crate::edit::style_range(&mut text.runs, byte, byte + 1, |st| { + st.features = vec!["salt=2".into()]; + change(st); + }); + let after = layout(&db, &text); + assert_eq!(after.glyphs.iter().map(|g| g.gid).collect::>(), [face.glyph_for('f'), face.glyph_for('i')], "attribute {i} at {byte}"); + for (byte, glyph) in after.glyphs.iter().enumerate() { + assert_eq!((glyph.byte, glyph.len, glyph.run), (byte, 1, byte), "split glyphs keep their own drawing style"); + } + } + } +} + +#[test] +fn a_ligature_feature_still_applies_only_to_its_character_range() { + let db = db_with(ligature_caret_font(None)); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test face"); + let mut text = point("fifi", LIGATURE_CARET_FAMILY); + crate::edit::style_range(&mut text.runs, 0, 1, |st| st.features = vec!["-liga".into()]); + let after = layout(&db, &text); + let glyphs = after.glyphs.iter().map(|g| (g.gid, g.byte, g.len)).collect::>(); + let ligature = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("the fixture's unencoded ligature glyph")); + assert_eq!(glyphs, [(face.glyph_for('f'), 0, 1), (face.glyph_for('i'), 1, 1), (ligature, 2, 2)]); +} + /// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets, so /// setting one of them on the f of "fi" keeps the ligature. Vertical type reads them per glyph, so /// there the f is shaped apart from the i. diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index c2f90d0bef..8f19259126 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -29,12 +29,24 @@ //! distance along the path shows next to the pointer in the document's units, the drag is //! previewed and kept as one undo step on release, and Escape cancels it. //! +//! The Select/Move widgets: with one Objects on Path group selected, the same tools show a small +//! ring on each object's attachment point, highlighted under the pointer. A click on one chooses +//! that object (`object.objectsOnPath.selectMember`), and its ring is filled; the group stays +//! selected, and a click on the object's art still selects the group. Dragging a ring along the +//! path moves its object to the place of the object nearest the pointer, and the objects between +//! move one place toward where it was (`object.objectsOnPath.moveMember`): only their order along +//! the path changes, and hidden and locked objects keep their places. On an open path a pointer beyond an end takes the place at that end; on a +//! closed path the drag goes on round across the first anchor. The drag is previewed and kept as +//! one undo step on release, and Escape cancels it. +//! //! Behavior: , //! . use serde_json::{Value, json}; use vectorcraft_doc::blend::Spine; -use vectorcraft_doc::objects_on_path::{CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop}; +use vectorcraft_doc::objects_on_path::{ + CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop, stops, +}; use vectorcraft_doc::{Document, Node, NodeId, Selection}; use vectorcraft_geom::{BezPath, Point, Vec2}; @@ -68,6 +80,11 @@ pub const MOVE_ALL_HALF_PX: f32 = 4.5; /// How near (screen px) the pointer must be to hit the Move All widget. const MOVE_ALL_HIT_PX: f64 = 6.0; +/// The radius (screen px) of the Select/Move widget's ring, as the canvas draws it. +pub const MEMBER_RADIUS_PX: f32 = 4.5; +/// How near (screen px) the pointer must be to hit a Select/Move widget. +const MEMBER_HIT_PX: f64 = 6.0; + /// The Objects on Path group the widgets show for at `zoom` (screen px per document point), with /// its record, its children and its path: only when the selection is that one editable group, on /// a path with length, and no anchors are Direct-Selected. @@ -567,6 +584,189 @@ impl MoveAllDrag { } } +/// The Select/Move widget of one object of a selected Objects on Path group: a small ring on the +/// object's attachment point. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MemberWidget { + pub group: NodeId, + /// The object. + pub member: NodeId, + /// The object's place along the path (0: the first). + pub index: usize, + /// The object's attachment point, in the document: the widget's center. + pub at: Point, + /// Is the object the chosen one ([`Selection::on_path_member`])? + pub chosen: bool, +} + +impl MemberWidget { + /// The widgets of the selection at `zoom` (screen px per document point), one per object in + /// its order along the path that is neither hidden nor locked: only when the selection is one + /// editable Objects on Path group on a path with length, and no anchors are Direct-Selected. + pub fn all(doc: &Document, selection: &Selection, zoom: f64) -> Vec { + let Some((group, rec, _, path)) = one_group(doc, selection, zoom) else { return vec![] }; + let Some(at) = stops(path, rec.members.len(), rec.spacing, rec.start) else { return vec![] }; + let editable = doc.editable(); + rec.members + .iter() + .zip(at) + .enumerate() + .filter(|(_, (m, (p, _)))| p.x.is_finite() && p.y.is_finite() && editable(m.id)) + .map(|(index, (m, (at, _)))| Self { group, member: m.id, index, at, chosen: selection.on_path_member == Some(m.id) }) + .collect() + } + + /// The widget of `cx` nearest `p`, when `p` is within reach of it; of widgets as near, the + /// chosen one, else the first along the path. + fn at_point(cx: &ToolContext, p: Point) -> Option { + let reach = cx.tol(MEMBER_HIT_PX); + // Every attachment point lies on the path: a pointer beyond reach of its bounds is over none. + let (_, _, _, path) = one_group(cx.doc, cx.selection, cx.zoom)?; + if !path.control_bounds()?.inflate(reach, reach).contains(p) { + return None; + } + let mut best: Option<(f64, Self)> = None; + for w in Self::all(cx.doc, cx.selection, cx.zoom) { + let d = w.at.distance(p); + let nearer = best.is_none_or(|(b, bw)| d < b - 1e-9 || (d <= b + 1e-9 && w.chosen && !bw.chosen)); + if d <= reach && nearer { + best = Some((d, w)); + } + } + best.map(|(_, w)| w) + } +} + +/// Is `p` over a Select/Move widget? +pub fn over_member(cx: &ToolContext, p: Point) -> bool { + MemberWidget::at_point(cx, p).is_some() +} + +/// The highlight of the Select/Move widget under `p`, if any: a ring around it. +pub fn member_hover(cx: &ToolContext, p: Point) -> Option { + let w = MemberWidget::at_point(cx, p)?; + let r = cx.tol(f64::from(MEMBER_RADIUS_PX) + 2.0); + let ring = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(w.at.x - r, w.at.y - r, w.at.x + r, w.at.y + r)); + Some(Overlay::Path { path: ring.to_bezpath(), color: HIGHLIGHT, width: 1.5, dashed: false }) +} + +/// Dragging a Select/Move widget: the object goes to the place along the path nearest the +/// pointer, and the objects between it and its place move one place toward where it was, except +/// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond +/// an end takes the place at that end; on a closed path the pointer goes on round across the first +/// anchor. Where the path passes as near the pointer more than once, as where it runs back over +/// itself, the drag follows the part nearest along the path to where it was. Where two places are +/// as near, the one the drag is at stays. A click without a drag, and +/// the release of a drag, choose the object (`object.objectsOnPath.selectMember`). +#[derive(Clone, Debug)] +pub struct MemberDrag { + widget: MemberWidget, + /// The objects in their order along the path when the press began. + order: Vec, + /// The distance along the path to each place (points), in that order. + places: Vec, + /// Can the drag take each place? Not the place of a hidden or locked object. + open: Vec, + /// The length of the path (points). + len: f64, + /// Is the path closed? + closed: bool, + start: Point, + began: bool, + /// The distance along the path the drag was last at (points). + last: f64, + /// The place the drag puts the object at. + target: usize, +} + +impl MemberDrag { + /// The drag has left the click: the object is moving. + pub fn adjusting(&self) -> bool { + self.began + } + + /// Start a drag when the press `ev` is on a Select/Move widget. + pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { + let widget = MemberWidget::at_point(cx, ev.pos)?; + let n = cx.doc.node(widget.group)?; + let rec = n.extras().objects_on_path.as_deref()?; + let (len, closed) = path_length(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?)?; + let count = rec.members.len(); + let (s0, gap) = layout(len, closed, count, rec.spacing, rec.start); + let places: Vec = (0..count).map(|i| nth_length(len, closed, s0, gap, i)).collect(); + let last = *places.get(widget.index)?; + let editable = cx.doc.editable(); + let open = rec.members.iter().map(|m| editable(m.id)).collect(); + let order = rec.members.iter().map(|m| m.id).collect(); + Some(Self { widget, order, places, open, len, closed, start: ev.pos, began: false, last, target: widget.index }) + } + + /// How far apart along the path the distances `a` and `b` are: the shorter way round a closed + /// path. + fn apart(&self, a: f64, b: f64) -> f64 { + let d = (a - b).abs(); + if self.closed { d.rem_euclid(self.len).min(self.len - d.rem_euclid(self.len)) } else { d } + } + + /// The place nearest the distance `t` along the path, of the places the drag can take; the + /// current one where another is as near. + fn nearest_place(&self, t: f64) -> usize { + let t = if self.closed { t } else { t.clamp(0.0, self.len) }; + let now = self.places.get(self.target).map_or(f64::INFINITY, |s| self.apart(t, *s)); + let mut best = (self.target, now); + for (i, s) in self.places.iter().enumerate().filter(|(i, _)| self.open.get(*i).copied().unwrap_or(false)) { + let d = self.apart(t, *s); + if d.is_finite() && d < best.1 - 1e-9 { + best = (i, d); + } + } + best.0 + } + + /// The pointer moved to `p`. + pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec { + let mut out = vec![]; + if !self.began { + if p.distance(self.start) < cx.tol(3.0) { + return out; + } + self.began = true; + out.push(Action::Begin("Move Object on Path".into())); + } + let w = &self.widget; + let Some(spine) = group_spine(cx.doc, w.group) else { return out }; + let Some((near, t)) = spine.nearest(p, None) else { return out }; + // Where the path passes as near the pointer more than once, the point nearest along the path + // to where the drag was counts. + let t = match spine.nearest(p, Some(self.last)) { + Some((d, s)) if d - spine.apart(s, self.last) / 2.0 - near <= 1e-9 * (1.0 + near) => s, + _ => t, + }; + if !t.is_finite() { + return out; + } + self.last = t; + let target = self.nearest_place(t); + if target != self.target { + self.target = target; + let to = self.order.get(target).copied().unwrap_or(w.member); + out.push(Action::Preview("object.objectsOnPath.moveMember".into(), json!({ "group": w.group.0, "member": w.member.0, "target": to.0 }))); + } + out + } + + /// The release: a drag that moved the object keeps it as one undo step, one that ends where it + /// began keeps nothing, and either way the object is chosen. + pub fn finish(self) -> Vec { + let choose = Action::Exec("object.objectsOnPath.selectMember".into(), json!({ "member": self.widget.member.0 })); + match (self.began, self.target == self.widget.index) { + (false, _) => vec![choose], + (true, true) => vec![Action::Cancel, choose], + (true, false) => vec![Action::Commit, choose], + } + } +} + /// The path of the Objects on Path group `g` of `doc`, to measure along. fn group_spine(doc: &Document, g: NodeId) -> Option { let n = doc.node(g)?; @@ -602,6 +802,10 @@ pub struct ObjectsOnPathTool { spacing: Option, /// The Move All widget drag, while the button is down after a press on the widget. moving: Option, + /// The Select/Move widget drag, while the button is down after a press on a widget. + member: Option, + /// Where the pointer is while no button is down, for the Select/Move widget it is over. + pointer: Option, /// The reference point of each object attached next (0-8, row by row from the top left). pivot: u8, /// The angle of each object attached next, in degrees counter-clockwise from upright on the path. @@ -610,7 +814,7 @@ pub struct ObjectsOnPathTool { impl Default for ObjectsOnPathTool { fn default() -> Self { - Self { hover: None, press: None, rotating: None, spacing: None, moving: None, pivot: CENTER, rotate: 0.0 } + Self { hover: None, press: None, rotating: None, spacing: None, moving: None, member: None, pointer: None, pivot: CENTER, rotate: 0.0 } } } @@ -642,9 +846,18 @@ impl Tool for ObjectsOnPathTool { _ => vec![], }; } + if let Some(d) = &mut self.member { + return match ev.kind { + PointerKind::Drag => d.drag(cx, p), + PointerKind::Up => self.member.take().map(MemberDrag::finish).unwrap_or_default(), + _ => vec![], + }; + } + self.pointer = (ev.kind == PointerKind::Move).then_some(p); match ev.kind { PointerKind::Move => { - self.hover = if over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) { None } else { target(cx, p) }; + let widget = over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) || over_member(cx, p); + self.hover = if widget { None } else { target(cx, p) }; vec![] } PointerKind::Down => { @@ -663,6 +876,11 @@ impl Tool for ObjectsOnPathTool { self.hover = None; return vec![]; } + if let Some(d) = MemberDrag::hit(cx, ev) { + self.member = Some(d); + self.hover = None; + return vec![]; + } self.press = Some(p); vec![] } @@ -689,16 +907,22 @@ impl Tool for ObjectsOnPathTool { if let Some(d) = &self.moving { o.extend(d.overlays(cx)); } + if let Some(p) = self.pointer.filter(|_| !self.busy()) { + o.extend(member_hover(cx, p)); + } o } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + if self.member.is_some() { + return Cursor::Move; + } let idle = self.spacing.is_none() && self.moving.is_none(); if self.rotating.is_some() || (idle && over_rotate_all(cx, p)) { Cursor::Rotate } else if self.spacing.is_some() || (self.moving.is_none() && over_space(cx, p)) { Cursor::ArrowHollow - } else if self.moving.is_some() || over_move_all(cx, p) { + } else if self.moving.is_some() || over_move_all(cx, p) || over_member(cx, p) { Cursor::Move } else if target(cx, p).is_some() { Cursor::ObjectsOnPath @@ -708,8 +932,8 @@ impl Tool for ObjectsOnPathTool { } /// Escape while the button is down cancels the click, so the release attaches nothing, or the - /// Rotate All, Space or Move All drag, so the objects go back to the Rotate, Space or place - /// they had. + /// Rotate All, Space, Move All or Select/Move drag, so the objects go back to the Rotate, Space, + /// place or order they had. fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape { self.press = None; @@ -717,7 +941,8 @@ impl Tool for ObjectsOnPathTool { let rotated = self.rotating.take().is_some_and(|d| d.adjusting()); let spaced = self.spacing.take().is_some_and(|d| d.adjusting()); let moved = self.moving.take().is_some_and(|d| d.adjusting()); - if rotated || spaced || moved { + let ordered = self.member.take().is_some_and(|d| d.adjusting()); + if rotated || spaced || moved || ordered { return vec![Action::Cancel]; } } @@ -747,13 +972,14 @@ impl Tool for ObjectsOnPathTool { } fn busy(&self) -> bool { - self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some() + self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some() || self.member.is_some() } fn transforming(&self) -> bool { self.rotating.as_ref().is_some_and(RotateAllDrag::adjusting) || self.spacing.as_ref().is_some_and(SpaceDrag::adjusting) || self.moving.as_ref().is_some_and(MoveAllDrag::adjusting) + || self.member.as_ref().is_some_and(MemberDrag::adjusting) } } @@ -1469,4 +1695,267 @@ mod tests { assert!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)).is_empty(), "{}: the release keeps nothing", t.id()); } } + + /// A document holding an Objects on Path group of `n` 20 × 20 squares attached by their centers + /// to `path`, with the group's Space `spacing`, a selection of the group, and the squares' ids + /// in their order along the path. + fn members_doc(path: vectorcraft_geom::PathData, n: usize, spacing: Option) -> (Document, NodeId, Selection, Vec) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (line, g) = (d.alloc_id(), d.alloc_id()); + let line = Node::path(line, path, Appearance::default_art()); + let squares: Vec = (0..n) + .map(|i| { + let x = 50.0 * i as f64; + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 50.0, x + 20.0, 70.0)), Appearance::default_art()) + }) + .collect(); + let ids: Vec = squares.iter().map(|s| s.id).collect(); + let rec = vectorcraft_doc::objects_on_path::ObjectsOnPath { + spacing, + ..vectorcraft_doc::objects_on_path::ObjectsOnPath::new(line.id, &squares.iter().collect::>(), CENTER, 0.0) + }; + let mut children = vec![Arc::new(line)]; + children.extend(squares.into_iter().map(Arc::new)); + let mut group = Node::new(g, NodeKind::Group { children, clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + vectorcraft_doc::objects_on_path::place(&mut group, &Document::new(1.0, 1.0).symbol_boxes()); + d.insert(Some(l), 0, group).unwrap(); + let mut sel = Selection::default(); + sel.set([g]); + (d, g, sel, ids) + } + + /// Three squares on the line from (0, 300) to (400, 300): at 0, 200 and 400. + fn three_doc() -> (Document, NodeId, Selection, Vec) { + members_doc(shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), 3, None) + } + + /// The `target` a drag's actions preview for `object.objectsOnPath.moveMember`, if any. + fn previewed_target(acts: &[Action]) -> Option { + acts.iter().rev().find_map(|a| match a { + Action::Preview(cmd, p) if cmd == "object.objectsOnPath.moveMember" => p["target"].as_u64(), + _ => None, + }) + } + + fn choose(id: NodeId) -> Action { + Action::Exec("object.objectsOnPath.selectMember".into(), json!({ "member": id.0 })) + } + + #[test] + fn a_select_move_widget_sits_on_each_attachment_point() { + let (d, g, sel, ids) = three_doc(); + let ws = MemberWidget::all(&d, &sel, 1.0); + let at: Vec<(NodeId, usize, Point, bool)> = ws.iter().map(|w| (w.member, w.index, w.at, w.chosen)).collect(); + assert!(ws.iter().all(|w| w.group == g)); + assert_eq!(at.len(), 3); + for (i, x) in [0.0, 200.0, 400.0].into_iter().enumerate() { + assert!(at[i].0 == ids[i] && at[i].1 == i && near(at[i].2, Point::new(x, 300.0)) && !at[i].3, "{at:?}"); + } + let mut chosen = sel.clone(); + chosen.on_path_member = Some(ids[1]); + assert_eq!(MemberWidget::all(&d, &chosen, 1.0).iter().map(|w| w.chosen).collect::>(), [false, true, false]); + // None for an object in the group, with anchors Direct-Selected, or for a locked group. + let mut one = Selection::default(); + one.set([ids[0]]); + assert!(MemberWidget::all(&d, &one, 1.0).is_empty()); + let mut anchors = sel.clone(); + anchors.anchors.insert(ids[0], [(0, 0)].into()); + assert!(MemberWidget::all(&d, &anchors, 1.0).is_empty()); + let (mut locked, lg, lsel, _) = three_doc(); + locked.node_mut(lg).unwrap().locked = true; + assert!(MemberWidget::all(&locked, &lsel, 1.0).is_empty()); + } + + /// A click on a widget chooses its object without moving it, with the Selection tool and the + /// Objects on Path tool; a click on the object's art away from the widget does not. + #[test] + fn a_click_on_a_select_move_widget_chooses_its_object() { + let (d, _, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Move, 202.0, 301.0)); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Path { color, .. } if *color == HIGHLIGHT)), "{}: highlighted", t.id()); + assert_eq!(t.cursor(&c, Point::new(202.0, 301.0), Mods::default()), Cursor::Move, "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Down, 202.0, 301.0)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 203.0, 301.0)).is_empty(), "{}: within the click", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 203.0, 301.0)), vec![choose(ids[1])], "{}", t.id()); + assert!(!t.busy()); + // On the art, 8 px from the attachment point: not the widget. + t.pointer(&c, &ev(PointerKind::Down, 208.0, 300.0)); + let acts = t.pointer(&c, &ev(PointerKind::Up, 208.0, 300.0)); + assert!(!acts.contains(&choose(ids[1])), "{}: {acts:?}", t.id()); + } + } + + /// A widget at an end of the path is within reach from beyond the path's bounds too, up to + /// 6 px away. + #[test] + fn a_select_move_widget_at_an_end_is_hit_from_beyond_the_path() { + let (d, _, sel, _) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + assert!(over_member(&c, Point::new(-5.0, 300.0)), "before the start"); + assert!(over_member(&c, Point::new(405.0, 300.0)), "after the end"); + assert!(over_member(&c, Point::new(0.0, 305.5)), "beside the line"); + assert!(!over_member(&c, Point::new(0.0, 306.5)), "out of reach"); + assert!(!over_member(&c, Point::new(-6.5, 300.0)), "out of reach before the start"); + } + + /// Dragging the middle object's widget past the last one moves it to the last place; dragging + /// the last one's beyond the start of the line moves it to the first place. Each drag is one + /// undo step, and the release chooses the object. + #[test] + fn dragging_a_select_move_widget_moves_its_object_to_the_nearest_place() { + let (d, g, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, 200.0, 300.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 280.0, 310.0)); + assert_eq!(a, vec![Action::Begin("Move Object on Path".into())], "{}: still nearest its own place", t.id()); + assert!(t.transforming() && t.cursor(&c, Point::new(280.0, 310.0), Mods::default()) == Cursor::Move); + let a = t.pointer(&c, &ev(PointerKind::Drag, 330.0, 300.0)); + assert!( + matches!(a.as_slice(), [Action::Preview(cmd, p)] if cmd == "object.objectsOnPath.moveMember" && *p == json!({ "group": g.0, "member": ids[1].0, "target": ids[2].0 })), + "{}: {a:?}", + t.id() + ); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 600.0, 300.0)).is_empty(), "{}: beyond the end, the last place", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 600.0, 300.0)), vec![Action::Commit, choose(ids[1])], "{}", t.id()); + assert!(!t.busy() && !t.transforming()); + // The last one, back before the start of the line. + t.pointer(&c, &ev(PointerKind::Down, 400.0, 300.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 250.0, 300.0)); + assert_eq!(previewed_target(&a), Some(ids[1].0), "{}: {a:?}", t.id()); + let a = t.pointer(&c, &ev(PointerKind::Drag, -80.0, 300.0)); + assert_eq!(previewed_target(&a), Some(ids[0].0), "{}: {a:?}", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, -80.0, 300.0)), vec![Action::Commit, choose(ids[2])], "{}", t.id()); + } + } + + /// A drag that comes back to its own place previews the object back there and keeps nothing on + /// release; Escape cancels a drag. + #[test] + fn a_select_move_drag_back_to_its_place_or_escaped_keeps_nothing() { + let (d, _, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 190.0, 300.0))), Some(ids[1].0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 20.0, 300.0))), Some(ids[0].0), "{}: back onto itself", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 20.0, 300.0)), vec![Action::Cancel, choose(ids[0])], "{}", t.id()); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 390.0, 300.0))).is_some()); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{}", t.id()); + assert!(!t.busy() && !t.transforming(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 0.0, 300.0)).is_empty(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)).is_empty(), "{}: the release keeps nothing", t.id()); + } + } + + /// Round a closed path the drag goes on across the first anchor: the first object, dragged back + /// past it, takes the last place. + #[test] + fn a_select_move_drag_goes_round_a_closed_path_across_its_first_anchor() { + // The square's outline is 400 pt round, from (100, 100) to the right and on clockwise: the + // four objects sit on its corners. + let (d, _, sel, ids) = members_doc(shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 100.0, 130.0))).is_none(), "nearer its own place"); + let a = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 180.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 180.0)), vec![Action::Commit, choose(ids[0])]); + // Forward from the last one across the first anchor: the first place. + t.pointer(&c, &ev(PointerKind::Down, 100.0, 200.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 120.0)); + assert_eq!(previewed_target(&a), Some(ids[0].0), "{a:?}"); + } + + /// On an open path that turns back on itself, a drag takes the place nearest the pointer even + /// when that place is far along the path from where the drag is. + #[test] + fn a_select_move_drag_takes_the_place_under_the_pointer_on_a_path_that_turns_back() { + // The path runs 210 pt from (0, 0) right, down and back left to (0, 10): the four objects + // sit at 0, 70, 140 and 210 pt along it, the last one 10 pt below the first. + let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 10.0), Point::new(0.0, 10.0)]; + let path = vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&corners, false)]); + let (d, _, sel, ids) = members_doc(path, 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 0.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 0.0, 9.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 9.0)), vec![Action::Commit, choose(ids[0])]); + } + + /// Where an open path runs back over itself, a drag along the part that runs back stays on that + /// part. + #[test] + fn a_select_move_drag_follows_the_part_it_is_on_where_a_path_runs_back_over_itself() { + // The path runs 540 pt from (0, 0) right to (300, 0) and back left to (60, 0): the four + // objects sit at 0, 180, 360 and 540 pt along it, at x 0, 180, 240 and 60. + let corners = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(60.0, 0.0)]; + let path = vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&corners, false)]); + let (d, _, sel, ids) = members_doc(path, 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 240.0, 0.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 220.0, 0.0)); + assert!(previewed_target(&a).is_none(), "nearer its own place: {a:?}"); + let a = t.pointer(&c, &ev(PointerKind::Drag, 80.0, 0.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 80.0, 0.0)), vec![Action::Commit, choose(ids[2])]); + } + + /// A hidden or locked object has no widget, and a drag does not take its place: the first + /// object, dragged onto the locked last one's place, stays at the nearest place it can take. + #[test] + fn a_locked_object_has_no_select_move_widget_and_keeps_its_place() { + let (mut d, _, sel, ids) = three_doc(); + d.node_mut(ids[2]).unwrap().locked = true; + assert_eq!(MemberWidget::all(&d, &sel, 1.0).iter().map(|w| w.member).collect::>(), ids[..2]); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 400.0, 300.0)); + assert!(t.pointer(&c, &ev(PointerKind::Up, 400.0, 300.0)).iter().all(|a| *a != choose(ids[2])), "no widget to press"); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 390.0, 300.0))), Some(ids[1].0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 390.0, 300.0)), vec![Action::Commit, choose(ids[0])]); + } + + /// Of widgets on the same point (no Space between the objects), a press takes the chosen + /// object's, else the first one's; between places as near, a drag keeps the place it is at. + #[test] + fn stacked_select_move_widgets_take_the_chosen_one_and_ties_keep_the_place() { + let (d, _, mut sel, ids) = members_doc(shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), 3, Some(0.0)); + let paint = paint(); + { + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[0])]); + // Every place is as far as the others: the drag stays where it is. + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 200.0, 300.0))).is_none()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 200.0, 300.0)), vec![Action::Cancel, choose(ids[0])]); + } + sel.on_path_member = Some(ids[2]); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[2])], "the chosen one"); + } } diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs index 49a5a9a3c1..095e0d21cf 100644 --- a/crates/tools/src/params.rs +++ b/crates/tools/src/params.rs @@ -78,6 +78,14 @@ pub fn gradient_params(g: &GradientPaint) -> Value { if !g.gradient.method.is_classic() { v["method"] = json!(g.gradient.method.label().to_lowercase()); } + // A freeform gradient has no ends to spread past (`paint.setFill` refuses a spread for one). + if g.gradient.is_spread() { + v["spread"] = json!(g.gradient.spread.label().to_lowercase()); + } + // A gradient that pads keeps its count for later. + if g.gradient.repeat_count() != 1 { + v["repeats"] = json!(g.gradient.repeat_count()); + } v } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 1658346127..3af4a84ea7 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -47,7 +47,9 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; -use crate::objects_on_path::{MoveAllDrag, RotateAllDrag, SpaceDrag, over_move_all, over_rotate_all, over_space}; +use crate::objects_on_path::{ + MemberDrag, MoveAllDrag, RotateAllDrag, SpaceDrag, member_hover, over_member, over_move_all, over_rotate_all, over_space, +}; use crate::pathtype::{self, BracketDrag, over_bracket}; use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; @@ -135,6 +137,8 @@ enum State { Space(SpaceDrag), /// Dragging an Objects on Path group's Move All widget. MoveAll(MoveAllDrag), + /// Pressing or dragging the Select/Move widget of an object of an Objects on Path group. + Member(MemberDrag), /// Move or rotate a live mirror axis. Mirror(crate::mirror::MirrorDrag), /// Drag a grid repeat's row, column or corner handle or a spacing slider. @@ -753,7 +757,7 @@ impl Tool for SelectionTool { /// E during a marquee drag switches the marquee between the objects it touches and only the /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or - /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space or Move All widget, the brackets of type on a path, a mirror + /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space, Move All or Select/Move widget, the brackets of type on a path, a mirror /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape /// drops the text. @@ -845,6 +849,7 @@ impl Tool for SelectionTool { State::RotateAll(d) => d.adjusting(), State::Space(d) => d.adjusting(), State::MoveAll(d) => d.adjusting(), + State::Member(d) => d.adjusting(), State::Bracket(b) => b.adjusting(), State::Mirror(d) => d.adjusting(), State::GridRepeat(d) => d.adjusting(), @@ -1042,6 +1047,15 @@ impl Tool for SelectionTool { self.state = State::Idle; d.finish() } + (PointerKind::Drag, State::Member(mut d)) => { + let out = d.drag(cx, p); + self.state = State::Member(d); + out + } + (PointerKind::Up, State::Member(d)) => { + self.state = State::Idle; + d.finish() + } (PointerKind::Drag, State::Bracket(mut b)) => { let out = b.drag(cx, p, m.cmd); self.state = State::Bracket(b); @@ -1110,6 +1124,9 @@ impl Tool for SelectionTool { State::Loaded { press: Some((start, cur)), .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), _ => {} } + if let (Some(p), State::Idle) = (self.bracket_hover, &self.state) { + o.extend(member_hover(cx, p)); + } o.extend(self.guides.iter().cloned()); o.extend(self.guide.overlays(cx)); if let (Some((q, label)), State::Idle, true) = (self.hover, &self.state, cx.smart_guides) { @@ -1158,7 +1175,7 @@ impl Tool for SelectionTool { State::Pie(_) => return Cursor::ArrowHollow, State::RotateAll(_) => return Cursor::Rotate, State::Space(_) => return Cursor::ArrowHollow, - State::MoveAll(_) => return Cursor::Move, + State::MoveAll(_) | State::Member(_) => return Cursor::Move, State::Bracket(_) => return Cursor::PathBracket, State::Loaded { .. } => return Cursor::LoadedText, _ => {} @@ -1184,7 +1201,7 @@ impl Tool for SelectionTool { if over_space(cx, p) { return Cursor::ArrowHollow; } - if over_move_all(cx, p) { + if over_move_all(cx, p) || over_member(cx, p) { return Cursor::Move; } if over_bracket(cx, p) { @@ -1536,7 +1553,7 @@ impl SelectionTool { self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; return Some(vec![]); } - // 1. Live Corners widgets, pie widgets, the Rotate All, Space and Move All widgets and type on a path's brackets, then the + // 1. Live Corners widgets, pie widgets, the Rotate All, Space, Move All and Select/Move widgets and type on a path's brackets, then the // bounding-box handles of the current selection. if let Some(c) = CornerDrag::hit(cx, ev, false) { self.state = State::Corner(c); @@ -1558,6 +1575,10 @@ impl SelectionTool { self.state = State::MoveAll(d); return Some(vec![]); } + if let Some(d) = MemberDrag::hit(cx, ev) { + self.state = State::Member(d); + return Some(vec![]); + } if let Some(b) = BracketDrag::hit(cx, ev) { self.state = State::Bracket(b); return Some(vec![]); diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index db3aad793e..c5e05f004b 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -47,7 +47,7 @@ pub fn paint_color_at(paint: &Paint, bounds: Option, p: Paint::Solid { color, .. } => Some(*color), Paint::Gradient(g) => { let geom = g.geom.or_else(|| bounds.map(|b| g.resolve(b)))?; - let t = geom.param_at(g.gradient.kind, p); + let t = g.gradient.spread_param(geom.param_at(g.gradient.kind, p)); Some(g.gradient.sample(t.clamp(0.0, 1.0) as f32).0) } _ => None, @@ -1543,7 +1543,7 @@ mod tests { let d = doc_with_live_over_green(|d| { let a = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 10.0); let red = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 420.0, 320.0)), a); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let content = vec![std::sync::Arc::new(red)]; let n = Node::new( d.alloc_id(), @@ -1574,7 +1574,7 @@ mod tests { use vectorcraft_geom::{Affine, Rect}; let d = doc_with_live_over_green(|d| { let red = solid_rect(d, Rect::new(300.0, 300.0, 420.0, 320.0), Color::rgb(1.0, 0.0, 0.0)); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let content = vec![std::sync::Arc::new(red)]; let options = Default::default(); Node::new( diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 6c3c76862c..9c8881b580 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -68,6 +68,8 @@ const CENTRE: f64 = 4.5; const CENTRE_HIT: f64 = 8.0; /// A stop dragged farther than this from the bar is deleted on release. const OFF_BAR: f64 = 24.0; +/// Half the length of the ticks across the bar where the runs of a repeating gradient meet. +const TICK: f64 = 5.0; /// A press becomes a drag after this much movement. const DRAG_START: f64 = 3.0; /// Arrow-key nudges of the selected stop (Shift: the big step). @@ -287,6 +289,7 @@ fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec { if len < 1e-12 { return o; } + o.extend(run_ticks(cx, a)); let r = cx.tol(4.0); let (along, across) = (a.vector() / len * r, a.normal() * r); for i in 0..a.gradient.stops.len().saturating_sub(1) { @@ -302,6 +305,31 @@ fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec { o } +/// Where each run of the stops of a gradient that repeats or reflects meets the next: a tick across +/// the bar at each such place along the vector, or, for a conic, a tick across its extent ellipse +/// at each such turn. None for a gradient that pads. +fn run_ticks(cx: &ToolContext, a: &Annotator) -> Vec { + if !a.gradient.is_spread() { + return vec![]; + } + let n = a.gradient.repeat_count(); + let conic = a.gradient.kind == GradientKind::Conic; + let frame = a.geom.conic_frame(); + let tick = a.normal() * cx.tol(TICK); + (1..n) + .flat_map(|k| { + let t = f64::from(k) / f64::from(n); + let (p, q) = if conic { + let (sin, cos) = (t * std::f64::consts::TAU).sin_cos(); + (frame * Point::new(0.9 * cos, 0.9 * sin), frame * Point::new(1.1 * cos, 1.1 * sin)) + } else { + (a.at(t) - tick, a.at(t) + tick) + }; + [Overlay::Line { a: p, b: q, color: LIGHT, dashed: false }, Overlay::Line { a: p, b: q, color: BAR, dashed: true }] + }) + .collect() +} + /// The vector a click applies to `id`: its current gradient's, else the default fit on its box. fn click_vector(cx: &ToolContext, id: NodeId) -> Option { let stroke = !cx.fill_active; diff --git a/crates/tools/src/xform/gradient/tests.rs b/crates/tools/src/xform/gradient/tests.rs index 943ed3af7b..bdfa981c34 100644 --- a/crates/tools/src/xform/gradient/tests.rs +++ b/crates/tools/src/xform/gradient/tests.rs @@ -453,3 +453,36 @@ fn a_radial_with_a_negative_aspect_drags_to_a_positive_one() { let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(100.0, 80.0)))).clone(); assert!((v["aspect"].as_f64().unwrap() - 70.0).abs() < 1e-9, "{v}"); } + +/// A gradient that repeats `n` times shows a tick across the bar where each run meets the next +/// (`n` - 1 of them), and a conic one across its extent at each such turn; one that pads shows +/// none. +#[test] +fn the_annotator_marks_where_the_runs_meet() { + use vectorcraft_color::GradientSpread; + let ticks = |kind: GradientKind, spread: GradientSpread| { + let (mut d, s) = graded(two()); + let n = d.node_mut(s.objects[0]).unwrap(); + let Paint::Gradient(mut g) = n.appearance.fill_paint() else { panic!("a gradient fill") }; + (g.gradient.kind, g.gradient.spread, g.gradient.repeats) = (kind, spread, 4); + let geom = g.geom.unwrap(); + n.appearance.set_fill(Paint::Gradient(g)); + let p = paint(); + let o = GradientTool::default().overlays(&cx(&d, &s, &p)); + let lines: Vec<(Point, Point)> = + o.iter().filter_map(|o| if let Overlay::Line { a, b, dashed: true, .. } = o { Some((*a, *b)) } else { None }).collect(); + (lines, geom) + }; + let (lines, geom) = ticks(GradientKind::Linear, GradientSpread::Repeat); + assert_eq!(lines.len(), 3, "{lines:?}"); + for (k, (a, b)) in lines.iter().enumerate() { + let at = geom.start + (geom.end - geom.start) * ((k + 1) as f64 / 4.0); + assert!(a.midpoint(*b).distance(at) < 1e-9, "tick {k} at {at:?}: {a:?} {b:?}"); + } + let (lines, geom) = ticks(GradientKind::Conic, GradientSpread::Reflect); + assert_eq!(lines.len(), 3); + let frame = geom.conic_frame(); + let quarter = frame * Point::new(0.0, 1.0); + assert!(lines[0].0.midpoint(lines[0].1).distance(quarter) < 1e-9, "{lines:?} {quarter:?}"); + assert!(ticks(GradientKind::Radial, GradientSpread::Pad).0.is_empty()); +} diff --git a/crates/trace/src/fit.rs b/crates/trace/src/fit.rs index 39fe3c974c..1ae81b14e8 100644 --- a/crates/trace/src/fit.rs +++ b/crates/trace/src/fit.rs @@ -4,7 +4,10 @@ use kurbo::ParamCurveArea; use vectorcraft_geom::{Anchor, AnchorKind, Point, SubPath}; use crate::contour::Loop; -use crate::logo::{Cubic, Stats, Style, all_within, fit_cubics, fit_open, fit_outline, resample_closed, resample_open, sample_cubics, tangent}; +use crate::logo::{ + Cubic, Stats, Style, all_within, fit_cubics, fit_open, fit_outline, resample_closed, resample_open, sample_cubics, smooth_closed, smooth_pinned, + tangent, +}; use crate::subpixel::{self, Crack}; #[cfg(test)] @@ -140,7 +143,9 @@ pub(crate) fn fit_closed(pts: &[Point], style: &Style) -> Option> { let mut segs = fit_outline(&pts, style, &mut stats)?; // A fit that strayed was replaced by a plain one that nothing has checked: one that strays too // (a wild handle on a tiny or ragged loop) gives way to the outline's own points. - if stats.reverted > 0 && !all_within(&sample_cubics(&segs, 0.25), &resample_closed(&pts, 0.5).map_or_else(|| pts.clone(), |(p, _)| p), 1.5) { + let followed = + resample_closed(&pts, 0.5).map_or_else(|| pts.clone(), |(p, step)| if style.contour { smooth_closed(&p, style.sigma / step) } else { p }); + if stats.reverted > 0 && !all_within(&sample_cubics(&segs, 0.25), &followed, 1.5) { segs = polygon(&pts, style.tol); } // A coarse fit of a tiny loop can be a single cubic. A closed editable @@ -250,8 +255,11 @@ pub(crate) fn fit_piece(pts: &[Point], style: &Style, fixed_ends: bool) -> Vec Vec (f64, u32), + across: &dyn Fn(Crack) -> (f64, u32, [f64; 5]), junction_at: &dyn Fn(i32, i32) -> Option, style: &Style, ) -> Option<(SubPath, bool)> { @@ -312,6 +330,21 @@ pub(crate) fn fit_loop( style.circle_tol *= 1.8; style.sigma = 0.9; } + if piece.contour { + style.sigma = CONTOUR_SIGMA; + if piece.closed { + // (The box is the same whichever way the loop runs.) + let (x0, x1, y0, y1) = + piece.pts.iter().fold((f64::MAX, f64::MIN, f64::MAX, f64::MIN), |b, p| (b.0.min(p.x), b.1.max(p.x), b.2.min(p.y), b.3.max(p.y))); + // Smoothing a loop of radius r by s draws it in by about s²/2r: an eighth of its + // width keeps that to about 3%. + style.sigma = style.sigma.min((x1 - x0).min(y1 - y0) / 8.0); + } + style.contour = true; + style.corner_deg = f64::INFINITY; + style.restore = None; + style.tol = style.tol.max(CONTOUR_TOL); + } if piece.closed { segs = fit_closed(&piece.pts, &style)?; } else { diff --git a/crates/trace/src/fit/junction_return_tests.rs b/crates/trace/src/fit/junction_return_tests.rs index fea2ea7dcc..0618a0969b 100644 --- a/crates/trace/src/fit/junction_return_tests.rs +++ b/crates/trace/src/fit/junction_return_tests.rs @@ -35,7 +35,8 @@ fn returning_piece_keeps_its_junction_and_interior() { let raw = Loop { pts: pts.clone(), area2: 2 * i64::from(width) }; let w = offset.0 as usize + 20; let h = offset.1 as usize + 20; - let (path, _) = fit_loop(&raw, w, h, &|_| (0., 0), &at, &style).expect("a positive-area returning loop must be editable"); + let (path, _) = + fit_loop(&raw, w, h, &|_| (0., 0, subpixel::EDGE_STEP), &at, &style).expect("a positive-area returning loop must be editable"); assert_eq!(path.anchors[0].p, Point::new(junction.0 as f64, junction.1 as f64)); let mut local = path.clone(); let shift = Point::new(offset.0 as f64, offset.1 as f64).to_vec2(); @@ -46,7 +47,7 @@ fn returning_piece_keeps_its_junction_and_interior() { } assert!(local.area().abs() > 0.9 * width as f64, "{width}, {turns}, {offset:?}"); let reverse = Loop { pts: pts.into_iter().rev().collect(), area2: -raw.area2 }; - let (back, _) = fit_loop(&reverse, w, h, &|_| (0., 0), &at, &style).unwrap(); + let (back, _) = fit_loop(&reverse, w, h, &|_| (0., 0, subpixel::EDGE_STEP), &at, &style).unwrap(); let mut expected = path; expected.reverse(); let start = expected.anchors.iter().position(|a| a.p == back.anchors[0].p).unwrap(); @@ -64,7 +65,7 @@ fn returning_piece_does_not_promote_a_zero_area_chain() { let style = crate::TraceParams { corners: 0., paths: 0., ..crate::TraceParams::default() }.outline_style(); // All four subpixel corners coincide; the raw pixel has area but its // positioned boundary does not. The fallback must not invent ink. - assert!(fit_loop(&raw, 4, 4, &|_| (-0.5, 0), &junction, &style).is_none()); + assert!(fit_loop(&raw, 4, 4, &|_| (-0.5, 0, subpixel::EDGE_STEP), &junction, &style).is_none()); } #[test] diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index 5050f4a2dd..e1a0539a9d 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -137,14 +137,7 @@ impl Layers<'_> { let tone = |l: u16| [if l == 1 { black } else { white }; 3]; (tone(la), tone(lb)) } else { - // A side's own colour: its palette entry's, or its gradient's colour here. - let own = |l: u16, k: i32| { - let flat = self.q.palette.get(l as usize)?.map(f64::from); - let Some(g) = self.gradients.get(l as usize).and_then(Option::as_ref) else { return Some(flat) }; - let i = c.at(k)?; - Some(g.color_at(vectorcraft_geom::Point::new((i % self.w) as f64 + 0.5, (i / self.w) as f64 + 0.5))) - }; - let (Some(ca), Some(cb)) = (own(la, 0), own(lb, 1)) else { return 0.0 }; + let (Some(ca), Some(cb)) = (self.own_color(la, c, 0), self.own_color(lb, c, 1)) else { return 0.0 }; (ca, cb) }; let d = [0, 1, 2].map(|k| cb[k] - ca[k]); @@ -152,10 +145,12 @@ impl Layers<'_> { if len2 <= 0.0 { return 0.0; } + let gray = self.params.mode == Mode::Grayscale; // How much of the inside tone (0 to 1) the pixel `k` steps out has; none off the image. + // Grayscale reads each pixel as the gray it traces it as. let inside = |k: i32| { - let Some(p) = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3)) else { return 0.0 }; - let p = [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]; + let Some(&[r, g, b, a]) = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 4)) else { return 0.0 }; + let p = if gray { [f64::from(super::quantize::luma([r, g, b, a])); 3] } else { [f64::from(r), f64::from(g), f64::from(b)] }; let p = if bw { [(p[0] * 299.0 + p[1] * 587.0 + p[2] * 114.0) / 1000.0; 3] } else { p }; 1.0 - ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::() / len2).clamp(0.0, 1.0) }; @@ -205,6 +200,85 @@ impl Layers<'_> { shift } + /// The color of label `l` at the pixel `k` steps out from the inside pixel of crack `c`: its + /// palette entry's, or its gradient's color there. + fn own_color(&self, l: u16, c: Crack, k: i32) -> Option<[f64; 3]> { + let flat = self.q.palette.get(l as usize)?.map(f64::from); + let Some(g) = self.gradients.get(l as usize).and_then(Option::as_ref) else { return Some(flat) }; + let i = c.at(k)?; + Some(g.color_at(vectorcraft_geom::Point::new((i % self.w) as f64 + 0.5, (i / self.w) as f64 + 0.5))) + } + + /// What the image does across a crack, for `subpixel::low_contrast`. Distances between colors + /// are RGB distances (0 to 255 a channel), and Grayscale reads each pixel as the gray it traces + /// it as. In order: + /// + /// - the share of the step from the inside color to the outside one that the image makes near + /// the crack, measured along the line between the two colors: from the mean of the two pixels + /// behind the inside pixel to the mean of the two beyond the outside one, each side only as + /// far as its own label goes; + /// - the share further out, from the next two pixels on one side to the next two on the other, + /// whatever their labels (not a number where a side has none); + /// - the near change along that line, as a distance; + /// - the whole near change, in any direction, as a distance; + /// - how much the image changes within each side: the distance between the two pixels behind + /// the inside pixel and between the two beyond the outside one, where both have the side's + /// label, averaged (not a number where neither side has two such pixels). + /// + /// Where a side has no pixel near the crack, and in Black and White, whose two tones are not the + /// colors of its areas, it is [`subpixel::EDGE_STEP`]. Exactly the same read from either side. + fn step(&self, c: Crack) -> [f64; 5] { + use super::subpixel::EDGE_STEP; + let label = |k: i32| c.at(k).and_then(|i| self.q.labels.get(i).copied()).filter(|&l| l != TRANSPARENT); + let (Some(la), Some(lb)) = (label(0), label(1)) else { return EDGE_STEP }; + if la == lb || self.params.mode == Mode::BlackAndWhite { + return EDGE_STEP; + } + let (Some(ca), Some(cb)) = (self.own_color(la, c, 0), self.own_color(lb, c, 1)) else { return EDGE_STEP }; + let d = [0, 1, 2].map(|k| cb[k] - ca[k]); + let len2: f64 = d.iter().map(|v| v * v).sum(); + if len2 <= 0.0 { + return EDGE_STEP; + } + let gray = self.params.mode == Mode::Grayscale; + // The pixel `k`, while it is in the image and not transparent (and has label `l`, if one is + // given). + let pixel = |k: i32, l: Option| { + label(k).filter(|&x| l.is_none_or(|l| x == l))?; + let &[r, g, b, a] = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 4))? else { return None }; + Some(if gray { [f64::from(super::quantize::luma([r, g, b, a])); 3] } else { [f64::from(r), f64::from(g), f64::from(b)] }) + }; + // The mean color of the pixels `ks` up to the first one `pixel` refuses. + let mean = |ks: [i32; 2], l: Option| { + let (mut sum, mut n) = ([0.0; 3], 0.0); + for p in ks.iter().map_while(|&k| pixel(k, l)) { + for (s, v) in sum.iter_mut().zip(p) { + *s += v; + } + n += 1.0; + } + (n > 0.0).then(|| sum.map(|s| s / n)) + }; + let distance = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (b[k] - a[k]).powi(2)).sum::().sqrt(); + // From `a` to `b` along the line from the inside color to the outside one, in its lengths. + let along = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (b[k] - a[k]) * d[k]).sum::() / len2; + let (Some(a), Some(b)) = (mean([-1, -2], Some(la)), mean([2, 3], Some(lb))) else { return EDGE_STEP }; + let far = match (mean([-3, -4], None), mean([4, 5], None)) { + (Some(a), Some(b)) => along(a, b), + _ => f64::NAN, + }; + let (mut within, mut sides) = (0.0, 0.0); + for ([j, k], l) in [([-1, -2], la), ([2, 3], lb)] { + if let (Some(p), Some(q)) = (pixel(j, Some(l)), pixel(k, Some(l))) { + within += distance(p, q); + sides += 1.0; + } + } + let within = if sides > 0.0 { within / sides } else { f64::NAN }; + let near = along(a, b); + [near, far, near * len2.sqrt(), distance(a, b), within] + } + /// Whether the pixels inside `l` (a loop along pixel edges) all have one label. fn one_colour_within(&self, l: &Loop) -> bool { let mut first = None; @@ -343,7 +417,8 @@ impl Layers<'_> { let hard = std::cell::Cell::new(false); // (Each loop fitted, and whether its own outline is hard.) let fit = |l: &Loop| { - let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?; + let (sp, is_hard) = + fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c), self.step(c)), &|x, y| self.junction(x, y), &self.style)?; // Every trace straightens the pieces of an outline that lie close to a line (Snap // Curves To Lines straightens more of them), so the outline of a single pixel or // of a region one pixel thick can have all its pieces on one chord, and the loop @@ -530,6 +605,185 @@ pub(crate) fn parallel_map_at_most(_: usize, items: &[T], f: i mod tests { use super::*; + /// `f` of the layers of a `w × 8` image whose pixels in column `x` are the color `color(x)` and + /// have the label `label(x)`, with the palette `palette`, and of the cracks between columns + /// `split - 1` and `split`. + fn across( + w: u32, + split: u32, + color: impl Fn(u32) -> [u8; 3], + label: impl Fn(u32) -> u16, + palette: &[[u8; 3]], + mode: Mode, + f: impl FnOnce(&Layers, Vec) -> R, + ) -> R { + let img = Raster::from_fn(w, 8, |x, _| { + let [r, g, b] = color(x); + [r, g, b, 255] + }); + let (w, h) = (w as usize, 8); + let q = Quantized { labels: (0..w * h).map(|i| label((i % w) as u32)).collect(), palette: palette.to_vec() }; + let params = TraceParams { mode, ..TraceParams::default() }; + let n = palette.len(); + let (rank, gradients): (Vec, Vec>) = ((0..n).collect(), vec![None; n]); + let job = Layers { + q: &q, + traced: &q.labels, + rank: &rank, + params: ¶ms, + img: &img, + left_out: vec![false; n], + boxes: vec![None; n], + gradients: &gradients, + bw_tones: [255.0, 0.0], + style: params.outline_style(), + w, + h, + anchors: AtomicUsize::new(0), + max_anchors: usize::MAX, + }; + let cracks = (0..h as i32).map(|y| Crack::new((split as i32 - 1, y), (1, 0), w, h)).collect(); + f(&job, cracks) + } + + /// [`steps_labeled`] with the label 0 left of column `split` and 1 from it. + fn steps(w: u32, split: u32, color: impl Fn(u32) -> [u8; 3], palette: &[[u8; 3]], mode: Mode) -> (Vec<[f64; 5]>, bool) { + steps_labeled(w, split, color, |x| u16::from(x >= split), palette, mode) + } + + /// [`Layers::step`] of every crack between columns `split - 1` and `split` of [`across`]'s image, + /// and whether `subpixel::low_contrast` calls them a contour. Each is read from both sides, which + /// must agree exactly. + fn steps_labeled( + w: u32, + split: u32, + color: impl Fn(u32) -> [u8; 3], + label: impl Fn(u32) -> u16, + palette: &[[u8; 3]], + mode: Mode, + ) -> (Vec<[f64; 5]>, bool) { + across(w, split, color, label, palette, mode, |job, cracks| { + let shares: Vec<[f64; 5]> = cracks + .into_iter() + .map(|c| { + assert_eq!(job.step(c).map(f64::to_bits), job.step(c.flipped()).map(f64::to_bits)); + job.step(c) + }) + .collect(); + let contour = crate::subpixel::low_contrast(&shares); + (shares, contour) + }) + } + + #[test] + fn a_crack_in_a_smooth_area_shows_a_share_of_the_step_that_grows_further_out() { + // Two levels a pixel between colors 40 apart: 135 and 143 two pixels either side (8 levels + // in each channel), 131 and 147 four pixels either side. + let ramp = |x: u32| [(100 + 2 * x) as u8; 3]; + for mode in [Mode::Color, Mode::Grayscale] { + let (shares, contour) = steps(40, 20, ramp, &[[120; 3], [160; 3]], mode); + // (The image changes along the line between the two colors, so its whole change is + // the change along that line; it changes by 2 levels a channel from pixel to pixel.) + let fits = |s: &[f64; 5]| { + (s[0] - 0.2).abs() < 1e-9 + && (s[1] - 0.4).abs() < 1e-9 + && [s[2], s[3]].iter().all(|v| (v - 8.0 * 3f64.sqrt()).abs() < 1e-9) + && (s[4] - 2.0 * 3f64.sqrt()).abs() < 1e-9 + }; + assert!(shares.iter().all(fits), "{mode:?}: {shares:?}"); + assert!(contour); + } + // Black and White's tones are no area's colors. + let (shares, contour) = steps(40, 20, ramp, &[[120; 3], [160; 3]], Mode::BlackAndWhite); + assert!(shares.iter().all(|s| s[0] == 1.0 && s[1] == 1.0 && s[2..].iter().all(|v| v.is_nan())) && !contour, "{shares:?}"); + // By the image's border there is nothing further out. + let (shares, contour) = steps(6, 3, ramp, &[[90; 3], [130; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[1].is_nan()) && contour, "{shares:?}"); + } + + #[test] + fn an_edge_between_flat_colors_is_an_edge_however_far_their_palette_entries_are() { + // A flat gray beside white, the gray in an entry with black: 115 of 245 levels, less than + // half the step between the two entries, but the same further out. + let flat = |x: u32| if x < 10 { [140; 3] } else { [255; 3] }; + let (shares, contour) = steps(20, 10, flat, &[[10; 3], [255; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[0] < 0.5 && s[0] == s[1] && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + // Two flat grays 6 levels apart, in entries 25 apart: under a half of the step and a + // change of under `FLAT`, but nothing changes within either side. + let close = |split: u32| move |x: u32| if x < split { [100; 3] } else { [106; 3] }; + let (shares, contour) = steps(20, 10, close(10), &[[90; 3], [115; 3]], Mode::Color); + assert!(shares.iter().all(|s| (s[0] - 0.24).abs() < 1e-9 && s[0] == s[1] && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + // The same by the image's border, where there is nothing further out. + let (shares, contour) = steps(6, 3, close(3), &[[90; 3], [115; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[1].is_nan() && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + // Two flat colors whose difference is square to the line between their entries: none of + // the step near the crack or further out. + let square = |x: u32| if x < 10 { [100, 100, 100] } else { [100, 106, 94] }; + let (shares, contour) = steps(20, 10, square, &[[90; 3], [115; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[0] == 0.0 && s[1] == 0.0 && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + } + + #[test] + fn an_edge_beside_a_thin_flat_stroke_is_an_edge() { + // White, then a gray stroke two pixels wide in an entry far darker than it (under a half + // of the step from white), then black: four pixels out from the crack the image reaches + // black, more than the whole step. + let color = |x: u32| match x { + 0..10 => [255; 3], + 10..12 => [200; 3], + _ => [0; 3], + }; + let label = |x: u32| match x { + 0..10 => 0, + 10..12 => 1, + _ => 2, + }; + let (shares, contour) = steps_labeled(20, 10, color, label, &[[255; 3], [140; 3], [0; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[0] < 0.5 && s[1] > 1.25 * s[0] && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + } + + #[test] + fn grayscale_reads_an_edge_between_two_colors_by_their_grays() { + // Red beside green: grays 60 and 117 (the palette's entries), whose channels add up to the + // same 200, so read as colors the image would make none of the step between them. + let flat = |x: u32| if x < 10 { [200, 0, 0] } else { [0, 200, 0] }; + let (shares, contour) = steps(20, 10, flat, &[[60; 3], [117; 3]], Mode::Grayscale); + assert!(shares.iter().all(|s| s[0] == 1.0 && s[1] == 1.0), "{shares:?}"); + assert!(!contour); + } + + #[test] + fn grayscale_places_an_edge_between_two_colors_by_their_grays() { + // The same red and green: by their grays, each is all of its own entry, so the edge lies on + // the crack. Read as colors, each would be mostly the other's. + let flat = |x: u32| if x < 10 { [200, 0, 0] } else { [0, 200, 0] }; + let shifts: Vec = across( + 20, + 10, + flat, + |x| u16::from(x >= 10), + &[[60; 3], [117; 3]], + Mode::Grayscale, + |job, cracks| cracks.into_iter().map(|c| job.shift(c)).collect(), + ); + assert!(shifts.iter().all(|&s| s == 0.0), "{shifts:?}"); + } + + #[test] + fn an_edge_between_colors_off_the_line_between_the_palettes_two_is_an_edge() { + // Red beside green traced with black and white entries: a change of 226 levels, none of it + // from black towards white. + let flat = |x: u32| if x < 10 { [160, 0, 0] } else { [0, 160, 0] }; + let (shares, contour) = steps(20, 10, flat, &[[0; 3], [255; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[0] == 0.0 && s[1] == 0.0 && (s[3] - 160.0 * 2f64.sqrt()).abs() < 1e-9), "{shares:?}"); + assert!(!contour); + } + #[test] fn fit_in_chunks_keeps_the_order_and_stops_at_the_first_refusal() { let items: Vec = (0..1000).collect(); diff --git a/crates/trace/src/logo.rs b/crates/trace/src/logo.rs index f3b5dd03d5..4f6d258b92 100644 --- a/crates/trace/src/logo.rs +++ b/crates/trace/src/logo.rs @@ -25,7 +25,7 @@ mod testutil; mod unmix; pub(crate) use bezier::{fit_cubics, tangent}; -pub(crate) use geom::{Cubic, all_within, resample_closed, resample_open, sample_cubics}; +pub(crate) use geom::{Cubic, all_within, resample_closed, resample_open, sample_cubics, smooth_closed, smooth_pinned}; pub(crate) use outline::{Stats, Style, fit_open, fit_outline}; pub(crate) use palette::parse_hex; pub(crate) use pipeline::trace_logo; diff --git a/crates/trace/src/logo/geom.rs b/crates/trace/src/logo/geom.rs index b33b358856..d81528e713 100644 --- a/crates/trace/src/logo/geom.rs +++ b/crates/trace/src/logo/geom.rs @@ -119,13 +119,18 @@ pub(crate) fn smooth_closed(p: &[Point], sigma: f64) -> Vec { } /// Gaussian smoothing of an open run with both ends pinned (the run is extended by odd reflection -/// so tangents at the ends survive). +/// so tangents at the ends survive), by at most an eighth of the run's samples. pub(crate) fn smooth_open(p: &[Point], sigma: f64) -> Vec { + smooth_pinned(p, sigma.max(0.4).min(p.len() as f64 / 8.0)) +} + +/// [`smooth_open`] by `sigma` (in samples, at least 0.4) whatever the run's length. +pub(crate) fn smooth_pinned(p: &[Point], sigma: f64) -> Vec { let n = p.len(); if n < 5 { return p.to_vec(); } - let s = sigma.max(0.4).min(n as f64 / 8.0).max(0.4); + let s = sigma.max(0.4); let k = kernel(s); let r = (k.len() - 1) / 2; let r_ext = r.min(n - 2); diff --git a/crates/trace/src/logo/outline.rs b/crates/trace/src/logo/outline.rs index 35f655b37f..9eaad508c9 100644 --- a/crates/trace/src/logo/outline.rs +++ b/crates/trace/src/logo/outline.rs @@ -3,6 +3,7 @@ use super::bezier::{fit_cubics, tangent}; use super::geom::{ Cubic, all_within, arc_cubics, circle_fit, find_corners, finite, resample_closed, resample_open, sample_cubics, smooth_closed, smooth_open, + smooth_pinned, }; use super::restore::{RestoreParams, restore_corners}; use vectorcraft_geom::Point; @@ -28,6 +29,10 @@ pub(crate) struct Style { /// Straight pieces within a few degrees of level or upright are made exactly so (by the /// tracer's other modes; Flat Logo leaves them). pub upright: bool, + /// The outline is the contour of a smooth area: a run between corners is smoothed by `sigma` + /// whatever its length (not by at most an eighth of it), and the fit is checked against the + /// outline smoothed by `sigma` (not against the outline itself). + pub contour: bool, } impl Default for Style { @@ -46,6 +51,7 @@ impl Default for Style { max_dev: 1.4, check_step: 0.03, upright: false, + contour: false, } } } @@ -111,7 +117,7 @@ fn fit_run(run: &[Point], sg: f64, st: &Style, stats: &mut Stats, segs: &mut Vec } return; } - let sm = smooth_open(run, sg); + let sm = if st.contour { smooth_pinned(run, sg) } else { smooth_open(run, sg) }; let (Some(&a), Some(&b)) = (sm.first(), sm.last()) else { return }; let len = a.distance(b); if st.snap && len > 1.5 { @@ -267,8 +273,10 @@ pub(crate) fn fit_outline(p: &[Point], st: &Style, stats: &mut Stats) -> Option< return None; } let mut local = Stats::default(); - // The reference is the raw outline, densified so the check never depends on how sparse it is. - let reference = resample_closed(p, st.check_step / 0.3).map_or_else(|| p.to_vec(), |(d, _)| d); + // The reference is the raw outline, densified so the check never depends on how sparse it is + // (and smoothed, for the contour of a smooth area). + let reference = resample_closed(p, st.check_step / 0.3) + .map_or_else(|| p.to_vec(), |(d, step)| if st.contour { smooth_closed(&d, st.sigma / step) } else { d }); let checked = |st: &Style, local: &mut Stats| { styled_fit(p, st, local).filter(|s| sane(s)).filter(|s| { let samples = sample_cubics(s, st.check_step); diff --git a/crates/trace/src/subpixel.rs b/crates/trace/src/subpixel.rs index 91a24a1da4..550fb87d2f 100644 --- a/crates/trace/src/subpixel.rs +++ b/crates/trace/src/subpixel.rs @@ -67,8 +67,62 @@ pub(crate) struct Piece { pub hard: bool, /// The piece is a whole loop without a junction. pub closed: bool, + /// The piece is a contour of a smooth area, not an edge (see [`low_contrast`]). + pub contour: bool, } +/// Whether a stretch of outline is the contour of a smooth area (where a gradient or a shaded +/// surface was split into bands of two colors) rather than an edge of the image, from what +/// `across` says of each of its cracks (see `Layers::step`): the share of the step from one color to +/// the other that the image makes from the second and third pixels on one side of the crack to those +/// on the other (near), from the fourth and fifth (far), the near change along the line between the +/// two colors, the whole near change in any direction, and the change within each side, each change +/// an RGB distance (0 to 255 a channel). An edge makes all of the step near the crack, +/// anti-aliased or not, and none further out; a smooth area makes a fraction of it near the crack +/// and a larger fraction further out, and changes within each side too. +/// +/// Here, over the stretch: +/// +/// - the near share is under one half; +/// - the far share is at least one and a quarter times the near one, or the near change along the +/// line is under [`FLAT`], or no crack has a far share; +/// - the sides change within themselves, by at least a thirty-second of the near change along the +/// line; +/// - and the whole near change is under twice the change along the line, or under twice [`FLAT`]. +/// +/// Between two flat colors that the palette put in entries further apart than they are, the near +/// share can be under one half, but nothing changes within either side. Between two colors off the +/// line between their entries, such as a red and a green traced with black and white, the shares can +/// be nothing at all, but the whole change is large. Values that are not a number are left out. The +/// means are summed in sorted order, so the stretch is decided the same from either side. +pub(crate) fn low_contrast(steps: &[[f64; 5]]) -> bool { + let mean = |k: usize| { + let mut v: Vec = steps.iter().map(|s| s[k]).filter(|s| !s.is_nan()).collect(); + v.sort_by(f64::total_cmp); + (!v.is_empty()).then(|| v.iter().sum::() / v.len() as f64) + }; + let along = mean(2); + if mean(3).is_some_and(|change| change >= 2.0 * along.unwrap_or(0.0).max(FLAT)) { + return false; + } + if !mean(4).is_some_and(|within| within > 0.0 && within >= along.unwrap_or(0.0) / 32.0) { + return false; + } + match (mean(0), mean(1)) { + (Some(near), Some(far)) => near < 0.5 && (far >= 1.25 * near || along.is_some_and(|l| l < FLAT)), + (Some(near), None) => near < 0.5, + _ => false, + } +} + +/// A change between colors (an RGB distance, 0 to 255 a channel) under which an area is nearly +/// flat (see [`low_contrast`]). +const FLAT: f64 = 12.0; + +/// What `across` says of a crack that is an edge whatever the image does there: a crack on the +/// image's border, or one that the image's pixels cannot be read across (see [`low_contrast`]). +pub(crate) const EDGE_STEP: [f64; 5] = [1.0, 1.0, f64::NAN, f64::NAN, f64::NAN]; + /// What lies across a crack on the image's border: one neighbour per side, so that the image's /// corners are junctions and stay exact. fn border(dir: (i32, i32)) -> u32 { @@ -84,7 +138,8 @@ fn border(dir: (i32, i32)) -> u32 { /// The outline of `l` (a loop of a `w × h` image) as points. /// /// `across` says, for a crack, how far (in pixels, −0.5 to 0.5, positive towards the outside -/// pixel) the edge lies from it, and which region is on its other side. +/// pixel) the edge lies from it, which region is on its other side, and what the image does across +/// it between the two regions' colors (see [`low_contrast`]; the same read from either side). /// /// The outline comes in pieces, cut at its junctions: the pixel corners where the region on the /// other side changes (three or more regions meet there, or the image's border begins), and @@ -100,18 +155,19 @@ pub(crate) fn outline( l: &Loop, w: usize, h: usize, - across: &dyn Fn(Crack) -> (f64, u32), + across: &dyn Fn(Crack) -> (f64, u32, [f64; 5]), junction_at: &dyn Fn(i32, i32) -> Option, ) -> Vec { let v = &l.pts; let n = v.len(); let at = |i: usize| Point::new(f64::from(v[i % n].0), f64::from(v[i % n].1)); if n < 4 { - return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }]; + return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true, contour: false }]; } let mut edges = Vec::with_capacity(n); - // Per crack: its shift, the region across it, and the edge it is on. - let (mut shifts, mut others, mut edge_of): (Vec, Vec, Vec) = (vec![], vec![], vec![]); + // Per crack: its shift, the region across it, the edge it is on, and what the image does + // across it between the two colors. + let (mut shifts, mut others, mut edge_of, mut steps): (Vec, Vec, Vec, Vec<[f64; 5]>) = (vec![], vec![], vec![], vec![]); for i in 0..n { let (a, b) = (v[i], v[(i + 1) % n]); let (dx, dy) = (b.0 - a.0, b.1 - a.1); @@ -128,19 +184,20 @@ pub(crate) fn outline( _ => ((sx, sy - 1), (sx - 1, sy - 1)), }; let crack = Crack { inside, step: (outside.0 - inside.0, outside.1 - inside.1), w, h }; - let (s, other) = match crack.at(1) { + let (s, other, step) = match crack.at(1) { Some(_) => across(crack), // The edge of the image is where it is. - _ => (0.0, border(dir)), + _ => (0.0, border(dir), EDGE_STEP), }; shifts.push(if s.is_finite() { (s.clamp(-0.5, 0.5) * 1024.0).round() / 1024.0 } else { 0.0 }); others.push(other); edge_of.push(i); + steps.push(step); } } let cracks = shifts.len(); if cracks == 0 { - return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }]; + return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true, contour: false }]; } // The pixel corner each crack starts at. let corner_of = |c: usize| { @@ -244,6 +301,7 @@ pub(crate) fn outline( let len = (1..cracks - c).find(|&k| junction((start + c + k) % cracks)).unwrap_or(cracks - c); let own: Vec = (0..len).map(|k| shifts[(start + c + k) % cracks]).collect(); let hard = is_hard(&own); + let contour = low_contrast(&(0..len).map(|k| steps[(start + c + k) % cracks]).collect::>()); let mut pts = Vec::with_capacity(len + 2); for k in 0..len { let crack = (start + c + k) % cracks; @@ -271,11 +329,11 @@ pub(crate) fn outline( let (x, y) = corner_of(next); pts.push(junctions[next].unwrap_or(Point::new(f64::from(x), f64::from(y)))); } - pieces.push(Piece { pts, hard, closed: first.is_none() }); + pieces.push(Piece { pts, hard, closed: first.is_none(), contour }); c += len; } if first.is_none() && pieces.first().is_some_and(|p| p.pts.len() < 3) { - return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }]; + return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true, contour: false }]; } pieces } @@ -297,7 +355,7 @@ mod tests { /// The outline with one neighbour all round (the image's border too): its points, and whether /// it is hard. fn whole(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64) -> (Vec, bool) { - let mut pieces = outline(l, w, h, &|c| (shift(c), 0), &grid); + let mut pieces = outline(l, w, h, &|c| (shift(c), 0, EDGE_STEP), &grid); let hard = pieces.iter().all(|p| p.hard); // Only the image's border makes junctions here: drop each piece's closing point. let split = pieces.len() > 1; @@ -354,7 +412,7 @@ mod tests { let (a, b) = (c.at(0).unwrap(), c.at(1).unwrap()); assert_eq!((c.at(-1), c.at(2)), (Some(a - 1), Some(b + 1))); assert_eq!((c.at(-3), c.at(4)), (None, None)); - (0.0, 0) + (0.0, 0, EDGE_STEP) }, &grid, ); @@ -366,6 +424,76 @@ mod tests { assert!(pts.contains(&Point::new(3.0, 0.0)) && pts.contains(&Point::new(3.0, 4.0))); } + #[test] + fn a_stretch_is_a_contour_when_it_shows_a_small_share_of_the_step_that_grows_further_out() { + // A smooth area: about a fifth of the step near the cracks, more further out, with noise. + assert!(low_contrast(&[[0.1, 0.5, 4.0, 9.0, 3.0], [0.3, 0.3, 12.0, 15.0, 4.0], [0.2, 0.4, 8.0, 11.0, 2.0]])); + assert!(low_contrast(&[[0.2, 0.26, 20.0, 24.0, 5.0], [0.2, 0.26, 20.0, 24.0, 5.0]])); + assert!(!low_contrast(&[[0.2, 0.24, 20.0, 24.0, 5.0], [0.2, 0.24, 20.0, 24.0, 5.0]])); + // Flat color beside flat color: the same share near the crack and further out. + assert!(!low_contrast(&[[0.45, 0.45, 40.0, 40.0, 1.0], [0.47, 0.5, 42.0, 42.0, 2.0], [0.46, 0.44, 41.0, 41.0, 1.0]])); + // A near change of a few levels, the same further out: a nearly flat area whose bands + // noise divides (and turns in any direction, also within each side). + assert!(low_contrast(&[[0.22, 0.22, 3.0, 20.0, 15.0], [0.24, 0.2, 3.5, 18.0, 12.0]])); + assert!(!low_contrast(&[[0.22, 0.22, 12.0, 14.0, 15.0], [0.24, 0.2, 12.0, 14.0, 12.0]])); + // Two close flat colors, the same near the crack and further out, and nothing changes + // within either side: no contour, however small their change. + assert!(!low_contrast(&[[0.24, 0.24, 10.4, 10.4, 0.0], [0.24, 0.24, 10.4, 10.4, 0.0]])); + // The same for two flat colors whose difference is square to the line between their + // entries: none of the step, and no change within the sides either. + assert!(!low_contrast(&[[0.0, 0.0, 0.0, 14.0, 0.0]])); + // The change within the sides must be at least a thirty-second of the change along the line. + assert!(low_contrast(&[[0.2, 0.4, 16.0, 16.0, 0.5]])); + assert!(!low_contrast(&[[0.2, 0.4, 16.0, 16.0, 0.49]])); + // An edge: all of the step. + assert!(!low_contrast(&[[1.0, 1.0, 40.0, 40.0, 0.0], [0.9, 1.0, 36.0, 37.0, 1.0]])); + // An edge between colors off the line between the two entries: none of the step, but a + // large change. The whole change must stay under twice the change along the line. + assert!(!low_contrast(&[[0.0, 0.0, 0.0, 226.0, 5.0], [0.0, 0.0, 0.0, 226.0, 5.0]])); + assert!(low_contrast(&[[0.2, 0.4, 14.0, 27.0, 4.0]])); + assert!(!low_contrast(&[[0.2, 0.4, 14.0, 28.0, 4.0]])); + // The whole stretch counts: one half and more near the crack is no contour. + assert!(!low_contrast(&[[0.2, 1.0, 8.0, 10.0, 2.0], [0.9, 1.0, 36.0, 36.0, 0.0]])); + // Without far shares (by the image's border), the near share decides, if the sides change. + assert!(low_contrast(&[[0.3, f64::NAN, 30.0, 32.0, 6.0], [0.2, f64::NAN, 20.0, 22.0, 5.0]])); + assert!(!low_contrast(&[[0.3, f64::NAN, 30.0, 32.0, 0.0], [0.2, f64::NAN, 20.0, 22.0, 0.0]])); + // Values that are not a number say nothing; no share at all, or no change within the + // sides at all, no contour. + assert!(low_contrast(&[[f64::NAN; 5], [0.2, 0.4, 20.0, 22.0, 4.0]])); + assert!(!low_contrast(&[])); + assert!(!low_contrast(&[[f64::NAN, 0.2, 3.0, 5.0, 1.0]])); + assert!(!low_contrast(&[[0.2, 0.4, 20.0, 22.0, f64::NAN]])); + } + + #[test] + fn a_contour_is_decided_for_the_whole_piece_and_the_same_from_either_side() { + // Left and right halves meet along a staircase; the cracks in the upper rows show a tenth of + // the step near them (4 levels) and three tenths further out, and change within each side, + // those in the lower ones all of it. + let (w, h) = (12, 10); + let left: Vec = (0..w * h).map(|i| (i % w) as f64 + 0.5 < 4.0 + (i / w) as f64 * 0.4).collect(); + let right: Vec = left.iter().map(|b| !b).collect(); + for (low_rows, contour) in [(9, true), (3, false)] { + // (By the upper of the crack's two pixels: the same from either side.) + let step = |c: Crack| { + if c.at(0).unwrap().min(c.at(1).unwrap()) / w < low_rows { [0.1, 0.3, 4.0, 4.0, 4.0] } else { [1.0, 1.0, 40.0, 40.0, 0.0] } + }; + let a = outline(&loop_of(&left, w, h), w, h, &|c| (0.0, 1, step(c)), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (0.0, 2, step(c)), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + assert_eq!((inner(&a).contour, inner(&b).contour), (contour, contour), "{low_rows} low rows"); + // The image's border is no contour: its cracks have nothing across them. + assert!( + a.iter() + .chain(&b) + .filter(|p| p.pts.iter().all(|q| q.x == 0.0 || q.y == 0.0 || q.x == w as f64 || q.y == h as f64)) + .all(|p| !p.contour) + ); + } + } + #[test] fn a_shift_that_is_not_a_number_moves_nothing() { let mask = [true, true, false, false, true, true, false, false]; @@ -400,7 +528,7 @@ mod tests { let across = |c: Crack| { let (a, b) = (c.at(0).unwrap(), c.at(1).unwrap()); let wave = 0.4 * ((a + b) as f64).sin(); - (if labels[a] < labels[b] { wave } else { -wave }, labels[b]) + (if labels[a] < labels[b] { wave } else { -wave }, labels[b], EDGE_STEP) }; let pieces = |label| { let mask: Vec = labels.iter().map(|&l| l == label).collect(); @@ -446,8 +574,8 @@ mod tests { let left: Vec = (0..w * h).map(|i| (i % w) as f64 + 0.5 < 4.0 + (i / w) as f64 * 0.4).collect(); let right: Vec = left.iter().map(|b| !b).collect(); let wave = |c: Crack| 0.4 * ((c.at(0).unwrap() + c.at(1).unwrap()) as f64).sin(); - let a = outline(&loop_of(&left, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 1), &grid); - let b = outline(&loop_of(&right, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 2), &grid); + let a = outline(&loop_of(&left, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 1, EDGE_STEP), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 2, EDGE_STEP), &grid); // The piece that is not on the image's border, from either side. let inner = |pieces: &[Piece]| { pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 4e138dfd5b..0d2b725efb 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1281,6 +1281,92 @@ fn hairlines_keep_their_colour_in_color_mode() { assert_eq!(q.labels.iter().filter(|&&l| l == black).count(), 8 * 80 * 2 - 64); } +/// Where a point lies across [`wavy_bands`]' gradient: the same all along each of its contours. +fn wavy_u(x: f64, y: f64) -> f64 { + x + 8.0 * (y / 23.0).sin() +} + +/// The dark square of [`wavy_bands`]: its left, top, right and bottom sides. +const WAVY_SQUARE: [f64; 4] = [100.3, 40.2, 132.6, 80.7]; + +/// A 256 × 128 gradient from blue to orange whose contours wave from top to bottom, with 6 levels +/// of noise per channel, and a dark square with crisp sides on it, drawn anti-aliased. +fn wavy_bands() -> Raster { + const LOW: [f64; 3] = [30.0, 60.0, 120.0]; + const HIGH: [f64; 3] = [230.0, 170.0, 60.0]; + Raster::from_fn(256, 128, |x, y| { + let mut sum = [0.0; 3]; + for i in 0..16 { + let (sx, sy) = (f64::from(x) + (f64::from(i % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(i / 4) + 0.5) / 4.0); + let [l, t, r, b] = WAVY_SQUARE; + let c = if (l..r).contains(&sx) && (t..b).contains(&sy) { + [20.0; 3] + } else { + let along = ((wavy_u(sx, sy) + 8.0) / 272.0).clamp(0.0, 1.0); + [0, 1, 2].map(|k| LOW[k] + (HIGH[k] - LOW[k]) * along) + }; + for k in 0..3 { + sum[k] += c[k] / 16.0; + } + } + let c = |k: usize| (sum[k] + 6.0 * jitter(x, y, k as u32)).round().clamp(0.0, 255.0) as u8; + [c(0), c(1), c(2), 255] + }) +} + +/// How far the borders between the bands of a [`wavy_bands`] trace stray from contours of the +/// gradient: the RMS, over points along every curve more than 6 px from the image's border and from +/// the square, of the point's [`wavy_u`] less the mean of its stretch (the points in a row along +/// one curve), over stretches that run at least 30 px down the image. Also returns their number. +fn contour_spread(res: &TraceResult) -> (f64, usize) { + use vectorcraft_geom::ParamCurve; + let [l, t, r, b] = WAVY_SQUARE; + let away = |p: Point| p.x > 6.0 && p.x < 250.0 && p.y > 6.0 && p.y < 122.0 && !(p.x > l - 6.0 && p.x < r + 6.0 && p.y > t - 6.0 && p.y < b + 6.0); + let mut stretches: Vec> = vec![]; + for sp in res.paths.iter().flat_map(|p| &p.path.subpaths) { + let mut stretch = vec![]; + for p in (0..sp.segment_count()).flat_map(|i| (0..64).map(move |k| sp.segment(i).eval(f64::from(k) / 64.0))) { + if away(p) { + stretch.push(p); + } else if !stretch.is_empty() { + stretches.push(std::mem::take(&mut stretch)); + } + } + stretches.push(stretch); + } + stretches.retain(|s| s.iter().map(|p| p.y).fold(f64::MIN, f64::max) - s.iter().map(|p| p.y).fold(f64::MAX, f64::min) >= 30.0); + let mut sum = 0.0; + let mut n = 0; + for s in &stretches { + let mean = s.iter().map(|p| wavy_u(p.x, p.y)).sum::() / s.len() as f64; + sum += s.iter().map(|p| (wavy_u(p.x, p.y) - mean).powi(2)).sum::(); + n += s.len(); + } + ((sum / n.max(1) as f64).sqrt(), stretches.len()) +} + +#[test] +fn the_borders_of_a_gradients_bands_follow_its_contours() { + let res = trace(&wavy_bands(), &sixteen_colors()); + // The square's sides are edges: it keeps its four corners. + let [l, t, r, b] = WAVY_SQUARE; + let square = res.paths.iter().find(|p| p.color.iter().all(|&c| c < 40)).expect("the dark square"); + let nearest = [Point::new(l, t), Point::new(r, t), Point::new(r, b), Point::new(l, b)].map(|corner| { + square.path.subpaths[0] + .anchors + .iter() + .filter(|a| a.kind == vectorcraft_geom::AnchorKind::Corner) + .map(|a| a.p.distance(corner)) + .fold(f64::MAX, f64::min) + }); + assert!(nearest.iter().all(|&d| d < 0.3), "the nearest corner anchors to the square's corners: {nearest:?} px"); + let (spread, stretches) = contour_spread(&res); + // Each of the borders between the 15 or so bands, once for the band on either side of it. + assert!(stretches >= 20, "{stretches} stretches"); + // (Smoothed as edges are, 1.13 px.) + assert!(spread < 0.9, "the borders stray {spread} px from the gradient's contours"); +} + #[test] fn the_bands_of_a_gradient_stay_apart() { let img = Raster::from_fn(256, 64, |x, y| [x as u8, (x / 2 + y) as u8, 200 - (x / 2) as u8, 255]); diff --git a/crates/ui-egui/src/addons/cutting.rs b/crates/ui-egui/src/addons/cutting.rs index 82fd2d6e3b..1fbdcf88c2 100644 --- a/crates/ui-egui/src/addons/cutting.rs +++ b/crates/ui-egui/src/addons/cutting.rs @@ -1,6 +1,7 @@ //! Cutting Tools in the interface: its submenu (Object › Plug-ins › Cutting Tools). Stencil //! Bridges… opens the generic command dialog (`ui.paramDialog`, `dialogs::command`) on the options -//! of `addon.cutting.bridges`. +//! of `addon.cutting.bridges`. Weed Box and Lines… opens it on the options of `addon.cutting.weed`, +//! with a Preview, and runs the command on OK. A Spacing above 0 is sent instead of the Count. use serde_json::json; @@ -10,10 +11,21 @@ use crate::menus::Item; pub const BRIDGES: &str = "addon.cutting.bridges"; /// Stencil Bridges › Direction: what the command's `direction` takes, by name. pub const BRIDGE_DIRECTIONS: &[(&str, &str)] = &[("Vertical", "vertical"), ("Horizontal", "horizontal"), ("Shortest Crossing", "shortest")]; +/// Weed Box and Lines' command. +pub const WEED: &str = "addon.cutting.weed"; +/// Weed Box and Lines › Direction: what the command's `direction` takes, by name. +pub const WEED_DIRECTIONS: &[(&str, &str)] = &[("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both", "both")]; /// Object › Plug-ins › Cutting Tools. pub fn menu() -> Vec { - vec![Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true}))] + vec![ + Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true})), + Item::Cmd( + "Weed Box and Lines…", + WEED, + json!({"margin": 9, "box": true, "direction": "both", "count": 1, "spacing": 0, "gap": 0, "weight": 0.3, "swatch": "[Registration]"}), + ), + ] } #[cfg(test)] diff --git a/crates/ui-egui/src/addons/cutting/tests.rs b/crates/ui-egui/src/addons/cutting/tests.rs index d5620f717b..2e69eab904 100644 --- a/crates/ui-egui/src/addons/cutting/tests.rs +++ b/crates/ui-egui/src/addons/cutting/tests.rs @@ -52,3 +52,94 @@ fn stencil_bridges_opens_its_dialog_and_runs_on_ok() { assert_eq!(steps(&app), n + 1); assert!(app.ui.status.contains("no islands"), "{}", app.ui.status); } + +/// Object › Plug-ins › Cutting Tools › Weed Box and Lines… opens the command dialog on its +/// defaults with Preview off. With Preview on the canvas shows the weed box and lines while the +/// dialog is open; Cancel takes them away with no step of history, and OK keeps them as one step. +#[test] +fn weed_box_and_lines_previews_cancels_and_runs_on_ok() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node_count(); + let (start, n, nodes) = (app.session.doc().unwrap().doc.clone(), steps(&app), count(&app)); + let open = |app: &mut VectorcraftApp| { + let entries = menus::menu_entries(app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Cutting Tools"] && e.label == "Weed Box and Lines…").unwrap(); + assert!(item.enabled); + let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &cmd, p); + }; + + open(&mut app); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), (super::WEED.to_string(), "Weed Box and Lines".to_string())); + assert_eq!((d.str("direction"), d.bool("box"), d.bool("preview")), ("both".to_string(), true, false)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Weed Box and Lines", "Margin", "Box", "Direction", "Both", "Count", "Spacing", "Gap", "Weight", "Swatch", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + // Every field label is translated. + crate::i18n::set_current(crate::i18n::Lang::from_code("de").expect("de registered")); + let german = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::i18n::set_current(crate::i18n::Lang::EN); + for s in ["Rand:", "Rahmen:", "Richtung:", "Farbfeld:"] { + assert!(german.contains(s), "{s} in {german}"); + } + assert_eq!(count(&app), nodes, "no preview without the checkbox"); + + // Preview on: the group with the box and four lines is on the canvas. + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true)); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert_eq!(count(&app), nodes + 6, "previewed: a group, the box and four lines"); + + // Cancel: as it was, with no step of history. + crate::dialogs::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, start, "Cancel takes the preview away"); + assert_eq!(steps(&app), n, "and adds no undo step"); + + // OK: one step. + open(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(steps(&app), n + 1, "one undo step"); + assert_eq!(count(&app), nodes + 6); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, start); + + // A Spacing above 0 is used instead of the Count: the 68 pt wide box in strips at most 20 pt + // wide takes three vertical lines, each cut in two by the rectangle. + app.run("select.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::set_field(d, "direction".into(), json!("vertical")); + crate::dialogs::set_field(d, "spacing".into(), json!(20)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(count(&app), nodes + 8, "a group, the box and six pieces of line"); + + // A Spacing below 0 is refused, not swapped for the Count. + app.run("edit.undo", json!({})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + let n = steps(&app); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "spacing".into(), json!(-5)); + let e = crate::dialogs::confirm(&mut app).unwrap_err().to_string(); + assert!(e.contains("`spacing` must be more than 0"), "{e}"); + assert_eq!(steps(&app), n, "no undo step"); + + // A Spacing with no value, or of 0 written with its unit, leaves the Count in use: a Count of 0 + // draws the box alone. + crate::dialogs::cancel(&mut app); + for spacing in [serde_json::Value::Null, json!("0 pt")] { + app.run("select.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::set_field(d, "spacing".into(), spacing.clone()); + crate::dialogs::set_field(d, "count".into(), json!(0)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(count(&app), nodes + 2, "{spacing}: a group and the box"); + app.run("edit.undo", json!({})).unwrap(); + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 3ce7143224..7865375b69 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -127,6 +127,7 @@ impl VectorcraftApp { updates_rt: Default::default(), frame_rate: Default::default(), canvas_rect: None, + canvas_ui_zoom: 1.0, control_rotate_press: None, hover_doc: None, titlebar_from_env: false, diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 0b87eb16b8..ce9aa6bf93 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,4 +1,4 @@ -use super::{GeometryStamp, InputGeneration, VectorcraftApp, canvas}; +use super::{GeometryStamp, InputGeneration, VectorcraftApp}; impl VectorcraftApp { /// The host iteration count: one increment per `App::logic`/`App::ui` iteration (or a @@ -9,8 +9,7 @@ impl VectorcraftApp { /// The canvas on screen in document coordinates (none before it is laid out). pub fn screen_frame(&self) -> Option { - let (rect, view) = (self.canvas_rect?, self.view()?); - let xf = canvas::Xf::new(rect, view); + let (rect, xf) = (self.canvas_rect?, self.canvas_xf()?); let px = |x: f32, y: f32| xf.delta_to_doc(egui::vec2(x, y)); Some(vectorcraft_tools::ScreenFrame { origin: xf.to_doc(rect.left_top()), diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index f88367cb60..0949b9290f 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -110,9 +110,16 @@ impl VectorcraftApp { self.views.get_mut(i) } + /// The mapping between the document and the canvas of the last frame, at the active view (none + /// before the canvas is laid out or without a document). + pub(crate) fn canvas_xf(&self) -> Option { + Some(crate::canvas::Xf::at(self.canvas_rect?, self.view()?, self.canvas_ui_zoom)) + } + pub fn view_info(&self) -> ViewInfo { ViewInfo { - zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), + // The tools measure their tolerances and widgets in the interface's points. + zoom: self.view().map_or(1.0, |v| v.screen_zoom(self.canvas_ui_zoom)), outline: self.outline_view(), smart_guides: self.ui.view.smart_guides, guides: self.ui.view.guides, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6e6806e075..202a0542a8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -98,8 +98,16 @@ pub struct Xf { } impl Xf { + /// The mapping of view `v` onto the canvas `rect`, in the points of an interface zoomed by + /// `ui_zoom` (Preferences › User Interface › UI Scaling): a document point covers the same + /// device pixels at every UI Scaling ([`View::screen_zoom`]). + pub fn at(rect: egui::Rect, v: &View, ui_zoom: f32) -> Self { + Self { rect, zoom: v.screen_zoom(ui_zoom), center: v.center, rot: v.rotation.to_radians() } + } + /// [`Self::at`] with the interface unscaled. + #[cfg(test)] pub fn new(rect: egui::Rect, v: &View) -> Self { - Self { rect, zoom: v.zoom, center: v.center, rot: v.rotation.to_radians() } + Self::at(rect, v, 1.0) } pub fn to_screen(&self, p: Point) -> Pos2 { let c = self.rect.center(); @@ -255,6 +263,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let bars = app.ui.screen_mode < 2; let (full, rect) = view_rects(app, outer, ui.ctx().pixels_per_point()); app.canvas_rect = Some(rect); + app.canvas_ui_zoom = ui.ctx().zoom_factor(); let fitted = app.view().is_some_and(|v| v.fitted); // A document's opening view is fitted to the canvas of the first frame that shows it, and the // window's layout can still move the canvas's edges over the next frames. Until the view is @@ -276,7 +285,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let resp = ui.interact(rect, egui::Id::unique("canvas"), Sense::click_and_drag()); canvas_input(app, ui, &resp, rect); let v = *app.view().unwrap_or(&View::default()); - let xf = Xf::new(rect, &v); + let xf = Xf::at(rect, &v, app.canvas_ui_zoom); // While the view pans, zooms or turns under the pointer, the art under it isn't hit-tested. let wait = hover_wait(&mut app.canvas.view_moved, ViewKey { rect, zoom: v.zoom, center: v.center, rotation: v.rotation }, ui.input(|i| i.time)); if let Some(secs) = wait { @@ -313,12 +322,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ); // View › Pixel Preview: the art as it rasterizes, one pixel per point, while a document pixel // is bigger than a screen pixel (below that the screen render already shows it). - let pixel = (app.ui.view.pixel_preview && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); + let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); // File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show // their pixels as they rasterize, hard-edged. let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); - let key = cache_key(app, ui, st, &t, &v, (w, h, ppp), (pixel, smooth_images, plugins)); + // The frame renders the view at its zoom in the interface's points, as `xf` maps it. + let drawn = v.on_screen(app.canvas_ui_zoom); + let key = cache_key(app, ui, st, &t, &drawn, (w, h, ppp), (pixel, smooth_images, plugins)); // Placed documents' bitmaps are being made: draw again when they are ready. if vectorcraft_render::placed_document::busy() { ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); @@ -348,7 +359,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { !waiting }); if wait.is_none() && (!same_frame || app.canvas.texture.is_none() || settle) { - let (w, h, view) = frame_view(pixel, (w, h), &v, ppp); + let (w, h, view) = frame_view(pixel, (w, h), &drawn, ppp); let opts = frame_options(app, &key, mask_view, smooth_images); draw_frame(app, ui, Frame { key: &key, doc: &doc, effects: &effects, size: (w, h), view, opts, pixel }); } @@ -446,7 +457,7 @@ fn canvas_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: // wheel, a pan, a Rotate View drag, the Zoom tool and a Presentation Mode click move the view // there. A dialog, floating panel or menu over the canvas hides it (Object Highlighting // showed through them: #4518), unless a drag that began on the canvas goes on there. - let moved = Xf::new(rect, app.view().unwrap_or(&View::default())); + let moved = Xf::at(rect, app.view().unwrap_or(&View::default()), app.canvas_ui_zoom); let dragging = ui.data(|d| d.get_temp::(drag_id()).is_some()); app.hover_doc = ui.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p) && (dragging || drag::on_canvas(ui, resp, *p))).map(|p| moved.to_doc(p)); @@ -1208,3 +1219,5 @@ fn selection_visuals( #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_ui_scaling; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 43e5df0bcd..5f06eb980d 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -14,7 +14,7 @@ use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape, pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { let (pointer, m, space, hover) = wheel_view(app, ui, resp, rect); let v = *app.view().unwrap_or(&View::default()); - let xf = Xf::new(rect, &v); + let xf = Xf::at(rect, &v, app.canvas_ui_zoom); // A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`), // so a tap whose press and lift come in one frame has no hover position: it presses (and // double-taps) where it was seen last (#491). @@ -227,10 +227,11 @@ fn wheel_view( // slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The // tool's events below take their document points from the view this moves to. if resp.hovered() { + let ui_zoom = app.canvas_ui_zoom; if (factor - 1.0).abs() > 1e-6 && let (Some(p), Some(vm)) = (hover, app.view_mut()) { - zoom_about(vm, rect, p, vm.zoom * factor); + zoom_about(vm, rect, p, vm.zoom * factor, ui_zoom); } if scroll != egui::Vec2::ZERO { scroll_view(app, ui, scroll); @@ -239,7 +240,7 @@ fn wheel_view( && let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view) && let Some(vm) = app.view_mut() { - rotate_about(vm, rect, p, deg); + rotate_about(vm, rect, p, deg, ui_zoom); } } (pointer, m, space, hover) @@ -328,12 +329,13 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) // Where the pointer was in the frame before: a zoom marquee moved with Space follows it. let before: Option = ui.data(|dd| dd.get_temp(drag_pos_id())); ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p)); + let ui_zoom = app.canvas_ui_zoom; match d { Drag::Pan { start, center, .. } => { let ppp = ui.ctx().pixels_per_point(); if let Some(vm) = app.view_mut() { vm.center = center; - pan_pixels(vm, ppp, whole_pixels(p - start, ppp)); + pan_pixels(vm, (ppp, ui_zoom), whole_pixels(p - start, ppp)); } } Drag::RotateView { start_angle, start_rot, snaps } => { @@ -360,7 +362,7 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) if exponent != 0.0 && let Some(vm) = app.view_mut() { - zoom_about(vm, rect, start, vm.zoom * exponent.exp()); + zoom_about(vm, rect, start, vm.zoom * exponent.exp(), ui_zoom); } ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved })); } @@ -446,6 +448,7 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po }); // Backtick as it was at the button-up: a key pressed or released later in the frame does not count. let backquote = backquote_at_release(ui, egui::PointerButton::Primary, f.backquote_before); + let ui_zoom = app.canvas_ui_zoom; match d { Drag::ZoomBox { start } => { let start = zoom_marquee_start(ui, start, before, p, space); @@ -456,16 +459,16 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po // The marquee's center and its size on screen set the view, at any rotation. vm.center = xf.to_doc(r.center()); let fill = (rect.width() / r.width()).min(rect.height() / r.height()); - vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0); + vm.zoom = (xf.zoom * f64::from(ui_zoom) * f64::from(fill)).clamp(0.0313, 640.0); } else { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt), ui_zoom); } } } // A click (not held, not dragged) steps the zoom as with the marquee. Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => { if let Some(vm) = app.view_mut() { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt), ui_zoom); } } Drag::ZoomScrub { .. } => {} diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 8ff6faaf79..88c573c5e4 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -238,14 +238,15 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: rotate_all_widget(p, xf, tool, &st.doc, &st.selection); } -/// The Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one Objects on Path -/// group selected). The Rotate All widget is joined by a line to the first object's attachment -/// point. It stays while dragged. +/// The Select/Move, Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one +/// Objects on Path group selected). The Rotate All widget is joined by a line to the first object's +/// attachment point. It stays while dragged. fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, RotateAllWidget}; if !matches!(tool, "selection" | "objectsOnPath") { return; } + member_widgets(p, xf, doc, selection); if let Some(w) = RotateAllWidget::of(doc, selection, xf.zoom) { let (on, c, color) = (xf.to_screen(w.pivot), xf.to_screen(w.at), c32(doc.layer_color(w.group))); p.line_segment([on, c], Stroke::new(1.0, color)); @@ -256,6 +257,23 @@ fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_d move_all_widget(p, xf, doc, selection); } +/// The Select/Move widgets: a ring on each object's attachment point, filled blue for the chosen +/// object. They are drawn under the other widgets, which a press takes first. +fn member_widgets(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { + use vectorcraft_tools::objects_on_path::{MEMBER_RADIUS_PX, MemberWidget}; + for w in MemberWidget::all(doc, selection, xf.zoom) { + let c = xf.to_screen(w.at); + if w.chosen { + p.circle(c, MEMBER_RADIUS_PX, MEMBER_CHOSEN, Stroke::new(1.0, Color32::WHITE)); + } else { + p.circle(c, MEMBER_RADIUS_PX, Color32::TRANSPARENT, Stroke::new(1.5, c32(doc.layer_color(w.group)))); + } + } +} + +/// The fill of the chosen object's Select/Move widget. +pub(super) const MEMBER_CHOSEN: Color32 = Color32::from_rgb(0x1f, 0x6f, 0xeb); + /// The Space widget (beside the Rotate All widget, with two or more objects on the path): a square /// joined by a line to the last object's attachment point. It stays while dragged. fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index c8dc007538..fb305d29dd 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -263,11 +263,11 @@ fn refit_resized(app: &mut VectorcraftApp, areas: &[(Win, egui::Rect)]) -> Vec 1.0).then(|| pixel_region(&xf)).flatten(); + let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); - let mut key = cache_key(app, ui, st, &t, &v, (pw, ph, ppp), (pixel, smooth_images, plugins)); + let drawn = v.on_screen(ui_zoom); + let mut key = cache_key(app, ui, st, &t, &drawn, (pw, ph, ppp), (pixel, smooth_images, plugins)); // The current document's drag and Image Trace hold are not this document's, and this view has // its own Outline or Preview. (key.dragging, key.trace_source, key.outline) = (false, None, v.outline); let opts = frame_options(app, &key, st.shown_mask(), smooth_images); - let (fw, fh, view) = frame_view(pixel, (pw, ph), &v, ppp); + let (fw, fh, view) = frame_view(pixel, (pw, ph), &drawn, ppp); let doc = st.doc.clone(); app.canvas.panes.draw(ui.ctx(), &painter, &xf, slot, (key, doc, ((fw, fh), view), opts)); let Some(st) = app.session.documents().get(w.0) else { return false }; diff --git a/crates/ui-egui/src/canvas/panes/tests.rs b/crates/ui-egui/src/canvas/panes/tests.rs index 91a0ca5737..9dc008a896 100644 --- a/crates/ui-egui/src/canvas/panes/tests.rs +++ b/crates/ui-egui/src/canvas/panes/tests.rs @@ -105,7 +105,7 @@ fn tile_shows_a_pane_for_each_document_fitted_to_it() { for (i, r) in docs.iter().zip(&rects) { let v = app.views[*i]; let shown = if *i == 2 { canvas } else { view_rects(&app, *r, ctx.pixels_per_point()).1 }; - assert!(v.fitted && (v.zoom - fit_zoom(shown, artboard, 0.0)).abs() < 1e-9, "document {i} fits its pane: {v:?}"); + assert!(v.fitted && (v.zoom - fit_zoom(shown, artboard, 0.0, 1.0)).abs() < 1e-9, "document {i} fits its pane: {v:?}"); assert_eq!(v.center, artboard.center()); } assert_eq!(app.canvas.panes.shown.len(), 2, "the other two documents are rendered for their panes"); @@ -115,6 +115,29 @@ fn tile_shows_a_pane_for_each_document_fitted_to_it() { assert_eq!(active, [false, false, true]); } +/// With the interface zoomed by Preferences › User Interface › UI Scaling, each tiled document +/// fits its pane with 30 interface points to spare: its zoom is the zoom that fits it in an +/// unscaled interface times the UI Scaling. +#[test] +fn tiled_documents_fit_their_panes_at_every_ui_scaling() { + for ui_zoom in [0.75_f32, 1.5, 2.0] { + let mut app = app_with(3); + let ctx = egui::Context::default(); + ctx.set_zoom_factor(ui_zoom); + let size = vec2(1200.0, 800.0) / ui_zoom; + frame_in(&mut app, &ctx, vec![], size); + app.run("window.arrange.tile", json!({})).unwrap(); + frame_in(&mut app, &ctx, vec![], size); + let (docs, rects) = panes(&app); + for (i, r) in docs.iter().zip(&rects).filter(|(i, _)| **i != 2) { + let shown = view_rects(&app, *r, ctx.pixels_per_point()).1; + let unscaled = ((f64::from(shown.width()) - 60.0) / 400.0).min((f64::from(shown.height()) - 60.0) / 300.0); + let v = app.views[*i]; + assert!(v.fitted && (v.zoom - unscaled * f64::from(ui_zoom)).abs() < 1e-9, "UI Scaling {ui_zoom}×: document {i} fits its pane: {v:?}"); + } + } +} + #[test] fn a_press_in_another_pane_makes_its_document_current_without_drawing() { let mut app = app_with(3); @@ -281,7 +304,7 @@ fn opening_a_document_while_tiled_fits_each_document_to_its_new_pane() { let pane = panes(&app).1[0]; let rect = view_rects(&app, pane, ctx.pixels_per_point()).1; let v = app.views[0]; - assert!((v.zoom - fit_zoom(rect, Rect::new(0.0, 0.0, 400.0, 300.0), 0.0)).abs() < 1e-9, "fitted to its new pane: {v:?}"); + assert!((v.zoom - fit_zoom(rect, Rect::new(0.0, 0.0, 400.0, 300.0), 0.0, 1.0)).abs() < 1e-9, "fitted to its new pane: {v:?}"); assert_ne!(v.zoom, side_by_side, "the pane changed size"); } @@ -315,7 +338,7 @@ fn a_resized_window_fits_the_tiled_documents_again_unless_one_was_zoomed() { let (_, rects) = panes(&app); for i in [0, 2] { let area = if i == 2 { app.canvas_rect.unwrap() } else { view_rects(&app, rects[i], ctx.pixels_per_point()).1 }; - assert!((app.views[i].zoom - fit_zoom(area, artboard, 0.0)).abs() < 1e-9, "document {i} fits its smaller pane: {:?}", app.views[i]); + assert!((app.views[i].zoom - fit_zoom(area, artboard, 0.0, 1.0)).abs() < 1e-9, "document {i} fits its smaller pane: {:?}", app.views[i]); } assert_eq!(app.views[1].zoom, zoomed.zoom, "the zoomed document keeps its zoom"); } diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index 4f50944cb3..5372cc083d 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -153,10 +153,13 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { match kind { TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())), TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), - TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())), + TextKind::OnPath { .. } => { + items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())); + center_on_path_items(app, &mut items); + } } - // Dynamic Text turns on or off for type on a path and area type (Block), as the Type menu's item does; - // left out for point type and type in a thread, which is VectorCraft's own rule + // Dynamic Text turns on or off for type on a path and area type (Block), and turns point type into area + // type with Block on, as the Type menu's item does; left out for type in a thread // (). if crate::menus::enabled(app, "type.dynamicText") { items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); @@ -350,6 +353,18 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } } +/// The Contextual Task Bar's Center on Top and Center on Bottom, as in the Type menu, for type on a +/// closed path. +fn center_on_path_items(app: &VectorcraftApp, items: &mut Vec<(&'static str, &'static str, &'static str, serde_json::Value)>) { + for (label, icon, id) in + [(tl!("Center on Top"), "dc-al-top", "type.pathCenter.top"), (tl!("Center on Bottom"), "dc-al-bottom", "type.pathCenter.bottom")] + { + if crate::menus::enabled(app, id) { + items.push((label, icon, id, json!({}))); + } + } +} + /// The Contextual Task Bar's Image Trace button: a trace with the Default preset. const TASK_BAR_IMAGE_TRACE: &str = "taskBar.imageTrace"; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 503178a22b..c023865524 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4144,11 +4144,11 @@ fn scrolls_and_pans_move_the_view_by_whole_device_pixels() { fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() { let view = View { zoom: 1.0, center: Point::new(100.0, 100.0), fitted: true, rotation: 0.0, artboard: 0, outline: false }; let mut v = view; - let rest = scroll_by(&mut v, 1, 1.0, vec2(0.4, -0.3), ScrollRest::default()); + let rest = scroll_by(&mut v, 1, (1.0, 1.0), vec2(0.4, -0.3), ScrollRest::default()); assert_eq!(v.center, view.center, "less than half a pixel does not move the view"); assert!((rest.x - 0.4).abs() < 1e-6 && (rest.y + 0.3).abs() < 1e-6); let mut again = v; - scroll_by(&mut again, 1, 1.0, vec2(0.4, -0.3), rest); + scroll_by(&mut again, 1, (1.0, 1.0), vec2(0.4, -0.3), rest); assert_eq!(again.center, Point::new(99.0, 101.0), "the second scroll adds the first's part"); let changed = [ ("zoomed", View { zoom: 2.0, ..v }, 1, 1.0), @@ -4159,7 +4159,7 @@ fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() { ]; for (name, before, doc, ppp) in changed { let mut after = before; - let left = scroll_by(&mut after, doc, ppp, vec2(0.4 / ppp, -0.3 / ppp), rest); + let left = scroll_by(&mut after, doc, (ppp, 1.0), vec2(0.4 / ppp, -0.3 / ppp), rest); assert_eq!(after.center, before.center, "{name}: the first scroll's part is dropped"); assert!((left.x - 0.4).abs() < 1e-6 && (left.y + 0.3).abs() < 1e-6, "{name}: {left:?}"); } @@ -4898,6 +4898,38 @@ fn a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room() assert_eq!(widgets(&mut app), 1, "one object"); } +/// A selected Objects on Path group shows a Select/Move widget on each object's attachment point +/// with the Selection and Objects on Path tools, the chosen object's filled blue. +#[test] +fn a_selected_objects_on_path_group_shows_a_select_move_widget_per_object() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let line = id(app.session.execute("shape.line", &json!({"x1": 20, "y1": 200, "x2": 380, "y2": 200})).unwrap()); + let a = id(app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap()); + let b = id(app.session.execute("shape.rectangle", &json!({"x": 80, "y": 40, "width": 20, "height": 20})).unwrap()); + let c = id(app.session.execute("shape.rectangle", &json!({"x": 120, "y": 40, "width": 20, "height": 20})).unwrap()); + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + app.session.execute("object.objectsOnPath.attach", &json!({"path": line})).unwrap(); + let ctx = egui::Context::default(); + let r = vectorcraft_tools::objects_on_path::MEMBER_RADIUS_PX; + let widgets = |app: &mut VectorcraftApp, fill: Color32| { + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(k) if k.radius == r && k.fill == fill)).count() + }; + let (ring, chosen) = (Color32::TRANSPARENT, super::overlays::MEMBER_CHOSEN); + app.select_tool("selection"); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (3, 0)); + app.run("object.objectsOnPath.selectMember", json!({"member": b})).unwrap(); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (2, 1), "the chosen one is filled"); + app.select_tool("objectsOnPath"); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (2, 1)); + app.select_tool("directSelection"); + assert_eq!(widgets(&mut app, ring) + widgets(&mut app, chosen), 0); + app.select_tool("selection"); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert_eq!(widgets(&mut app, ring) + widgets(&mut app, chosen), 0, "an object in the group, not the group"); +} + /// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`. fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec { let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() }; diff --git a/crates/ui-egui/src/canvas/tests_ui_scaling.rs b/crates/ui-egui/src/canvas/tests_ui_scaling.rs new file mode 100644 index 0000000000..d9e2a96edf --- /dev/null +++ b/crates/ui-egui/src/canvas/tests_ui_scaling.rs @@ -0,0 +1,214 @@ +//! Preferences › User Interface › UI Scaling zooms the interface and not the art: at a given zoom +//! a document point covers the same device pixels at every UI Scaling, and the pointer lands on the +//! art drawn under it. The Navigator's view box and tiled panes have their own tests +//! (`panels::navigator`, `canvas::panes`). + +use super::*; +use vectorcraft_engine::Session; + +/// The window, in points of the screen before UI Scaling. +const WINDOW: egui::Vec2 = vec2(1200.0, 900.0); + +/// One canvas frame in a `WINDOW` window at `native` device pixels a point, with the interface +/// zoomed by `ui_zoom`. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, (native, ui_zoom): (f32, f32), events: Vec) { + ctx.set_zoom_factor(ui_zoom); + let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, WINDOW / ui_zoom)), events, ..Default::default() }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native); + let mut out = ctx.run_ui(input, |ui| show(app, ui)); + out.textures_delta.clear(); +} + +/// A 512 × 512 pt document shown at `native` device pixels a point and UI Scaling `ui_zoom`, with a +/// controlled render worker, and the receiver of the frames it is asked to render. +fn shown(native: f32, ui_zoom: f32) -> (VectorcraftApp, egui::Context, std::sync::mpsc::Receiver) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 512, "height": 512})).unwrap(); + app.canvas.worker_started = true; + let (worker, jobs, _done) = crate::render_worker::Worker::test_channel(); + app.canvas.worker = Some(worker); + // The first frames set the UI zoom and fit the artboard. + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + assert!((ctx.pixels_per_point() - native * ui_zoom).abs() < 1e-4, "{} device pixels a point", ctx.pixels_per_point()); + (app, ctx, jobs) +} + +/// The view matrix of the last frame the canvas asked the worker to render. +fn rendered(jobs: &std::sync::mpsc::Receiver) -> Affine { + jobs.try_iter().last().expect("a frame was rendered").view +} + +/// The device pixels between two document points in the frame `view` renders. +fn device_length(view: Affine, a: Point, b: Point) -> f64 { + (view * b - view * a).hypot() +} + +/// At 100% a 128 pt edge covers 128 device pixels a point of the screen, whatever the UI Scaling, +/// in the rendered frame and in the canvas's pointer mapping; a fitted artboard draws at the zoom +/// the view reports and fits the canvas. +#[test] +fn a_document_point_covers_the_same_device_pixels_at_every_ui_scaling() { + let (a, b) = (Point::new(100.0, 100.0), Point::new(228.0, 100.0)); + for native in [1.0_f32, 2.0] { + for ui_zoom in [0.75_f32, 1.0, 1.5, 2.0] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, jobs) = shown(native, ui_zoom); + let device = |zoom: f64| 128.0 * zoom * f64::from(native); + + app.run("view.actualSize", json!({})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + assert_eq!(app.view().unwrap().zoom, 1.0, "{at}: Actual Size is 100%"); + let drawn = device_length(rendered(&jobs), a, b); + assert!((drawn - device(1.0)).abs() < 1e-6, "{at}: the frame draws 128 pt over {drawn} device pixels"); + let xf = app.canvas_xf().unwrap(); + let pointed = f64::from((xf.to_screen(b) - xf.to_screen(a)).length() * ctx.pixels_per_point()); + assert!((pointed - device(1.0)).abs() < 1e-3, "{at}: the pointer maps 128 pt to {pointed} device pixels"); + + app.run("view.fitArtboard", json!({})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let zoom = app.view().unwrap().zoom; + let view = rendered(&jobs); + let drawn = device_length(view, a, b); + assert!((drawn - device(zoom)).abs() < 1e-6, "{at}: at {zoom} the frame draws 128 pt over {drawn} device pixels"); + // The artboard fits the canvas with 30 interface points to spare on its closer sides. + let rect = app.canvas_rect.unwrap(); + let ppp = f64::from(ctx.pixels_per_point()); + let (lo, hi) = (view * Point::new(0.0, 0.0), view * Point::new(512.0, 512.0)); + let (w, h) = (f64::from(rect.width()) * ppp, f64::from(rect.height()) * ppp); + let spare = [lo.x, lo.y, w - hi.x, h - hi.y].into_iter().fold(f64::INFINITY, f64::min); + assert!((spare - 30.0 * ppp).abs() < 1.0, "{at}: {spare} device pixels to spare in {w} × {h}"); + } + } +} + +/// View › Pixel Preview shows document pixels while one is bigger than a device pixel: at 80% on +/// a screen of one device pixel a point it does not, whatever the UI Scaling. +#[test] +fn pixel_preview_compares_a_document_pixel_with_a_device_pixel() { + for (native, zoom, pixels) in [(1.0_f32, 0.8, false), (1.0, 1.25, true), (2.0, 0.8, true)] { + for ui_zoom in [0.75_f32, 1.0, 1.5, 2.0] { + let (mut app, ctx, jobs) = shown(native, ui_zoom); + app.ui.view.pixel_preview = true; + app.run("view.setZoom", json!({"zoom": zoom * 100.0})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let job = jobs.try_iter().last().expect("a frame was rendered"); + assert_eq!(job.key.pixel.is_some(), pixels, "{native} device pixels a point at {zoom}, UI Scaling {ui_zoom}×"); + } + } +} + +/// At 100% a 20 pt square 100 pt to the right of the view's center is 100 device pixels a point +/// to the right of the canvas's center: a click there selects it, the frame draws it there, and the +/// tools measure their tolerances in the interface's points, whatever the UI Scaling. +#[test] +fn a_click_at_the_true_size_position_selects_the_object_drawn_there_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 1.5), (1.0, 2.0)] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, jobs) = shown(native, ui_zoom); + app.run("view.actualSize", json!({})).unwrap(); + let c = app.view().unwrap().center; + let square = Point::new(c.x + 100.0, c.y); + let id = + app.session.execute("shape.rectangle", &json!({"x": square.x - 10.0, "y": square.y - 10.0, "width": 20, "height": 20})).unwrap()["id"] + .as_u64() + .unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let rect = app.canvas_rect.unwrap(); + let ppp = ctx.pixels_per_point(); + let p = rect.center() + vec2(100.0 * native / ppp, 0.0); + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(p)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(true)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(false)]); + let selected = app.session.active().unwrap().selection.objects.clone(); + assert_eq!(selected, vec![vectorcraft_doc::NodeId(id)], "{at}: a click at {p:?}"); + let device = rendered(&jobs) * square; + let drawn = rect.min + vec2(device.x as f32, device.y as f32) / ppp; + assert!((drawn - p).length() * ppp < 1.0, "{at}: drawn at {drawn:?}, not {p:?}"); + assert!((app.view_info().zoom - 1.0 / f64::from(ui_zoom)).abs() < 1e-9, "{at}: the tools' zoom is {}", app.view_info().zoom); + } +} + +/// A pinch zooms about the pointer: the document point under the pointer stays under it, whatever +/// the UI Scaling. +#[test] +fn a_pinch_zooms_about_the_pointer_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 2.0)] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, _jobs) = shown(native, ui_zoom); + let rect = app.canvas_rect.unwrap(); + let p = rect.center() + vec2(150.0, -90.0) / ui_zoom; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(p)]); + let (before, z0) = (app.canvas_xf().unwrap().to_doc(p), app.view().unwrap().zoom); + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(p), egui::Event::Zoom(1.25)]); + for _ in 0..4 { + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + } + let z1 = app.view().unwrap().zoom; + assert!(z1 > z0 * 1.01, "{at}: the pinch zooms in, {z0} → {z1}"); + let after = app.canvas_xf().unwrap().to_doc(p); + let moved = (after - before).hypot() * app.view().unwrap().screen_zoom(ui_zoom) * f64::from(ctx.pixels_per_point()); + assert!(moved < 1e-3, "{at}: {before:?} under the pointer moved {moved} device pixels to {after:?}"); + } +} + +/// With Animated Zoom off, the Zoom tool zooms to the area dragged across: the zoom grows by the +/// times the marquee fits in the canvas, and the marquee's area then fills the canvas, whatever +/// the UI Scaling. +#[test] +fn the_zoom_tools_marquee_fills_the_canvas_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 2.0)] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, _jobs) = shown(native, ui_zoom); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let rect = app.canvas_rect.unwrap(); + let (from, to) = (rect.center() - vec2(60.0, 20.0), rect.center() + vec2(40.0, 30.0)); + let xf = app.canvas_xf().unwrap(); + let (a, b, z0) = (xf.to_doc(from), xf.to_doc(to), app.view().unwrap().zoom); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(from)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(from, true)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(to)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(to, false)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let fill = f64::from((rect.width() / 100.0).min(rect.height() / 50.0)); + let z1 = app.view().unwrap().zoom; + assert!((z1 - z0 * fill).abs() < 1e-6, "{at}: {z0} → {z1}, not {}", z0 * fill); + let xf = app.canvas_xf().unwrap(); + let shown = xf.to_screen(b) - xf.to_screen(a); + let filled = (shown.x - rect.width()).abs().min((shown.y - rect.height()).abs()); + assert!(filled < 1e-2, "{at}: the marquee's area is {shown:?} in a {:?} canvas", rect.size()); + } +} + +/// A middle-button drag moves the art with the pointer: the document point under the press is +/// under the release, whatever the UI Scaling. +#[test] +fn a_pan_moves_the_art_with_the_pointer_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 2.0)] { + let (mut app, ctx, _jobs) = shown(native, ui_zoom); + let rect = app.canvas_rect.unwrap(); + let (from, to) = (rect.center(), rect.center() + vec2(60.0, -40.0)); + let pressed = app.canvas_xf().unwrap().to_doc(from); + let middle = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(from)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![middle(from, true)]); + for k in 1..=4 { + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))]); + } + frame(&mut app, &ctx, (native, ui_zoom), vec![middle(to, false)]); + let released = app.canvas_xf().unwrap().to_doc(to); + let ppp = f64::from(ctx.pixels_per_point()); + let zoom = app.view().unwrap().screen_zoom(ui_zoom); + assert!( + (released - pressed).hypot() * zoom * ppp < 1.0, + "{native} device pixels a point, UI Scaling {ui_zoom}×: {pressed:?} is now under the pointer as {released:?}" + ); + } +} diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 60cb6687b4..dae1060502 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -33,7 +33,7 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { let zoom = if how == "view.actualSize" { actual_size_zoom(app.session.prefs.display_print_size) } else { - fit_zoom(rect, target, app.view().map_or(0.0, |v| v.rotation)) + fit_zoom(rect, target, app.view().map_or(0.0, |v| v.rotation), app.canvas_ui_zoom) }; let Some(v) = app.view_mut() else { return }; v.center = target.center(); @@ -41,15 +41,15 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { v.zoom = zoom; } -/// The zoom at which `target`, turned `rotation` degrees with the view, fits in `rect` with a -/// 30-point margin on each side. -pub(super) fn fit_zoom(rect: egui::Rect, target: Rect, rotation: f64) -> f64 { +/// The zoom at which `target`, turned `rotation` degrees with the view, fits in `rect` (points of +/// an interface zoomed by `ui_zoom`) with a 30-point margin on each side. +pub(super) fn fit_zoom(rect: egui::Rect, target: Rect, rotation: f64, ui_zoom: f32) -> f64 { // The width and height of the box round the target turned with the view, at zoom 1. let (sn, cs) = rotation.to_radians().sin_cos(); let (w, h) = (target.width(), target.height()); let zx = (rect.width() as f64 - 60.0) / (w * cs.abs() + h * sn.abs()).max(1.0); let zy = (rect.height() as f64 - 60.0) / (w * sn.abs() + h * cs.abs()).max(1.0); - zx.min(zy).clamp(0.0313, 640.0) + (zx.min(zy) * f64::from(ui_zoom)).clamp(0.0313, 640.0) } /// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`). @@ -110,26 +110,27 @@ pub(crate) fn actual_size_zoom(display_print_size: bool) -> f64 { } /// Zoom view `vm` of the canvas `rect` to `zoom` (clamped to the zoom range), keeping the document -/// point under screen point `p` where it is. -pub(super) fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64) { - let before = Xf::new(rect, vm).to_doc(p); +/// point under screen point `p` where it is, with the interface zoomed by `ui_zoom`. +pub(super) fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64, ui_zoom: f32) { + let before = Xf::at(rect, vm, ui_zoom).to_doc(p); vm.zoom = zoom.clamp(0.0313, 640.0); - vm.center += before - Xf::new(rect, vm).to_doc(p); + vm.center += before - Xf::at(rect, vm, ui_zoom).to_doc(p); } /// Turn view `vm` of the canvas `rect` by `degrees` clockwise, keeping the document point under -/// screen point `p` where it is. -pub(super) fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64) { - let before = Xf::new(rect, vm).to_doc(p); +/// screen point `p` where it is, with the interface zoomed by `ui_zoom`. +pub(super) fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64, ui_zoom: f32) { + let before = Xf::at(rect, vm, ui_zoom).to_doc(p); vm.rotation = vectorcraft_geom::normalize_deg(vm.rotation + degrees); - vm.center += before - Xf::new(rect, vm).to_doc(p); + vm.center += before - Xf::at(rect, vm, ui_zoom).to_doc(p); } /// Move view `vm`'s content by `(dx, dy)` device pixels along the screen's axes, at `ppp` device -/// pixels a point: the canvas's frame moves by exactly those pixels. -pub(super) fn pan_pixels(vm: &mut View, ppp: f32, (dx, dy): (f64, f64)) { +/// pixels a point of an interface zoomed by `ui_zoom`: the canvas's frame moves by exactly those +/// pixels. +pub(super) fn pan_pixels(vm: &mut View, (ppp, ui_zoom): (f32, f32), (dx, dy): (f64, f64)) { let (sn, cs) = vm.rotation.to_radians().sin_cos(); - let k = vm.zoom * f64::from(ppp); + let k = vm.screen_zoom(ui_zoom) * f64::from(ppp); vm.center -= vectorcraft_geom::Vec2::new((dx * cs + dy * sn) / k, (-dx * sn + dy * cs) / k); } @@ -155,20 +156,21 @@ pub(super) struct ScrollRest { /// of another kind, another document) or the device pixels a point did. pub(super) fn scroll_view(app: &mut VectorcraftApp, ui: &Ui, by: egui::Vec2) { let ppp = ui.ctx().pixels_per_point(); + let ui_zoom = app.canvas_ui_zoom; let Some(doc) = app.session.active().map(|st| st.uid) else { return }; let Some(vm) = app.view_mut() else { return }; let id = egui::Id::unique("canvas-scroll-rest"); - let rest = scroll_by(vm, doc, ppp, by, ui.data(|d| d.get_temp(id)).unwrap_or_default()); + let rest = scroll_by(vm, doc, (ppp, ui_zoom), by, ui.data(|d| d.get_temp(id)).unwrap_or_default()); ui.data_mut(|d| d.insert_temp(id, rest)); } -/// [`scroll_view`] for view `vm` of document `doc` at `ppp` device pixels a point, after the -/// scrolls that left `rest`: what this scroll leaves. -pub(super) fn scroll_by(vm: &mut View, doc: u64, ppp: f32, by: egui::Vec2, rest: ScrollRest) -> ScrollRest { +/// [`scroll_view`] for view `vm` of document `doc` at `ppp` device pixels a point of an interface +/// zoomed by `ui_zoom`, after the scrolls that left `rest`: what this scroll leaves. +pub(super) fn scroll_by(vm: &mut View, doc: u64, (ppp, ui_zoom): (f32, f32), by: egui::Vec2, rest: ScrollRest) -> ScrollRest { let (x, y) = if rest.after == Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) { (rest.x, rest.y) } else { (0.0, 0.0) }; let (x, y) = (x + f64::from(by.x) * f64::from(ppp), y + f64::from(by.y) * f64::from(ppp)); let (dx, dy) = (x.round(), y.round()); - pan_pixels(vm, ppp, (dx, dy)); + pan_pixels(vm, (ppp, ui_zoom), (dx, dy)); ScrollRest { x: x - dx, y: y - dy, after: Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) } } @@ -279,7 +281,8 @@ pub(super) fn scroll_tracks(rect: egui::Rect, outer: egui::Rect) -> [egui::Rect; pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui::Rect, t: &Tokens) { let content = app.content_bounds(); let Some(v) = app.view().copied() else { return }; - let xf = Xf::new(rect, &v); + let ui_zoom = app.canvas_ui_zoom; + let xf = Xf::at(rect, &v, ui_zoom); let painter = ui.painter(); painter.rect_filled(egui::Rect::from_min_max(outer.max - vec2(SCROLL_BAR, SCROLL_BAR), outer.max), 0.0, t.ruler); let hover = ui.input(|i| i.pointer.hover_pos()); @@ -346,7 +349,7 @@ pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, o && let Some(vm) = app.view_mut() { vm.center = c; - pan_pixels(vm, ppp, whole_pixels(-by, ppp)); + pan_pixels(vm, (ppp, ui_zoom), whole_pixels(-by, ppp)); } if moved != egui::Vec2::ZERO { scroll_view(app, ui, -moved); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11edb6de6e..68729b861c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -253,7 +253,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { } let mut inspected = json!({ "scriptRunning": app.session.script_running(), - "scriptReport": app.session.script_report(), + "scriptReport": app.session.shown_script_report(), "tool": app.session.tool_id(), "toolOptions": app.session.tool_options(), "ui": ui, @@ -870,7 +870,7 @@ fn rotate_view_pointer( None => { let rect = app.canvas_rect.ok_or("no canvas yet")?; let view = app.view().ok_or("no document")?; - RotateViewPress { window, document_resets, xf: Xf::new(rect, view), start_angle: 0.0, start_rotation: view.rotation } + RotateViewPress { window, document_resets, xf: Xf::at(rect, view, app.canvas_ui_zoom), start_angle: 0.0, start_rotation: view.rotation } } }; // A gesture cannot turn a different document/view or resume after its document was reset. @@ -975,7 +975,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con Err(e) => return err(e), }; let view = app.view_info(); - let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + let xf = app.canvas_xf(); let screen = |e: &Value| e.get("space").and_then(Value::as_str) == Some("screen"); if !rotating && xf.is_none() && events.iter().any(screen) { return err("no canvas yet"); diff --git a/crates/ui-egui/src/dialogs/brush_art_options.rs b/crates/ui-egui/src/dialogs/brush_art_options.rs index c511bb2b58..8bc0e66a2e 100644 --- a/crates/ui-egui/src/dialogs/brush_art_options.rs +++ b/crates/ui-egui/src/dialogs/brush_art_options.rs @@ -10,8 +10,8 @@ //! Scatter: per value (`size`, `spacing`, `scatter` %, `rotation` °) `…Min`, `…Max` and `…Mode` //! (`fixed` | `random` | `pressure` | `tilt` | `bearing` | `rotation`), `rotationRelativeTo` (`page` | //! `path`). -//! Art: `width` (%), `widthMode` (`fixed` | `pressure`), `widthMax` (the width at the heaviest pen -//! pressure, %), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and +//! Art: `width` (%), `widthMode` (`fixed` | `pressure` | `tilt` | `bearing` | `rotation`), +//! `widthMax` (the width at the heaviest pen pressure, upright or at 180°, %), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and //! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`, //! `overlap` (bend on each run between sharp corners). //! Pattern: `scale`, `spacing` (%), `fit` (`stretch` | `addSpace` | `approximate`), `flipAlong`, @@ -254,12 +254,9 @@ fn scatter_mode(d: &Dialog, key: &str) -> Variation { Variation::parse(&d.str(&mode_key(key))).unwrap_or_default() } -/// How an art brush's width varies: Fixed or Pressure. +/// How an art brush's width varies: Fixed, or with the pen's pressure, tilt, bearing or rotation. fn art_width_mode(d: &Dialog) -> Variation { - match Variation::parse(&d.str("widthMode")) { - Some(Variation::Pressure) => Variation::Pressure, - _ => Variation::Fixed, - } + Variation::parse(&d.str("widthMode")).filter(|m| m.follows_pen()).unwrap_or(Variation::Fixed) } /// `brush.options` params from the fields. @@ -282,11 +279,12 @@ pub(super) fn params(d: &Dialog) -> Value { }, _ => ArtScale::Stretch, }; - let pressure = art_width_mode(d) == Variation::Pressure; + let mode = art_width_mode(d); + let pen = mode.follows_pen(); json!({ "width": clamped(d, "width", ArtBrush::WIDTH_RANGE), - "width_mode": if pressure { json!(Variation::Pressure.id()) } else { Value::Null }, - "width_max": if pressure { json!(clamped(d, "widthMax", ArtBrush::WIDTH_RANGE)) } else { Value::Null }, + "width_mode": if pen { json!(mode.id()) } else { Value::Null }, + "width_max": if pen { json!(clamped(d, "widthMax", ArtBrush::WIDTH_RANGE)) } else { Value::Null }, "scale": serde_json::to_value(scale).unwrap_or_default(), "direction": id_of(&parse_field::(d, "direction")), "flip_along": d.bool("flipAlong"), @@ -462,14 +460,14 @@ fn scatter_body(ui: &mut egui::Ui, d: &mut Dialog) { } fn art_body(ui: &mut egui::Ui, d: &mut Dialog) { - // Width, then the width at the heaviest pen pressure (only with Pressure) and how it varies. + // Width, then the second width (only when the width follows the pen) and how it varies. let rail = Tokens::get(ui.ctx()).input_border; let range = ArtBrush::WIDTH_RANGE; widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| { super::form::slider_field_named(ui, d, "width", tl!("Width:"), range.0..=range.1, "%", &|_| rail); let mode = art_width_mode(d); - number(ui, d, "widthMax", range, "%", mode == Variation::Pressure); - let modes = [Variation::Fixed, Variation::Pressure]; + number(ui, d, "widthMax", range, "%", mode.follows_pen()); + let modes = [Variation::Fixed, Variation::Pressure, Variation::Tilt, Variation::Bearing, Variation::Rotation]; if let Some(m) = widgets::dropdown(ui, "brush-art-width-mode", mode.label(), &modes.map(Variation::label), 104.0).and_then(|i| modes.get(i)) { d.fields.insert("widthMode".into(), json!(m.id())); } diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index 3ae028bb05..07577c127c 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -275,6 +275,29 @@ fn art_options_width_follows_the_pen_pressure() { assert!((drawn_height(&app, id) - before).abs() < 1e-6); } +/// Art Brush Options' Width menu also offers Tilt, Bearing and Rotation, each with the second +/// width, and shows the one set when it opens again. +#[test] +fn art_options_width_follows_tilt_bearing_and_rotation() { + let mut app = app(); + let (id, before) = brushed(&mut app, "Tapered Stroke"); + for (mode, label) in [("tilt", "Tilt"), ("bearing", "Bearing"), ("rotation", "Rotation")] { + app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap(); + set(&mut app, json!({"widthMode": mode, "widthMax": 300})); + assert!(shown(&mut app).contains(label), "{mode}"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap(); + assert_eq!((&def["width_mode"], &def["width_max"]), (&json!(mode), &json!(300.0)), "{mode}"); + // A stroke drawn without the pen's input is half way: 200 %. + let after = drawn_height(&app, id); + assert!((after - 2.0 * before).abs() < 0.01 * before, "{mode}: {before} then {after}"); + app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), (mode.into(), 300.0), "{mode}"); + super::super::cancel(&mut app); + } +} + /// A context with the app's fonts and theme, for [`frame`]. fn ctx() -> egui::Context { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index 54df0ab912..8e4678ae50 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -186,9 +186,22 @@ pub(crate) fn swatch_choices(doc: &vectorcraft_doc::Document) -> Value { Value::Array(choices.collect()) } -/// A feature tag without its `-` or `+`. +/// A feature tag without its `-` or `+` prefix and its `=N` value. fn bare(tag: &str) -> &str { - tag.trim_start_matches(['-', '+']) + let tag = tag.trim_start_matches(['-', '+']); + tag.split_once('=').map_or(tag, |(name, _)| name) +} + +/// Is the feature a tag of a style's list sets on? A `tag=N` of a valued feature is off for 0, +/// which a `-` prefix check misses; every other tag is on unless it starts with `-`. +fn tag_on(tag: &str) -> bool { + let name = bare(tag); + let valued = name == "swsh" || vectorcraft_text::EXTRA_FEATURES.contains(&name); + if valued && vectorcraft_text::OtFeatures::known_tag(tag) { + vectorcraft_text::OtFeatures::from_tags([tag]).feature_value(name).is_some() + } else { + !tag.starts_with('-') + } } /// The OpenType Features fields for a style's `features`: each control is unset unless the list @@ -201,9 +214,9 @@ fn opentype_fields(features: Option<&Value>) -> Map { let mut fields = Map::new(); for (field, tag) in OT_SWITCHES { let last = tags.iter().rev().find(|t| bare(t) == *tag); - fields.insert((*field).into(), last.map_or(Value::Null, |t| json!(!t.starts_with('-')))); + fields.insert((*field).into(), last.map_or(Value::Null, |t| json!(tag_on(t)))); } - let last_on = |tag: &str| tags.iter().rev().find(|t| bare(t) == tag).map(|t| !t.starts_with('-')); + let last_on = |tag: &str| tags.iter().rev().find(|t| bare(t) == tag).map(|t| tag_on(t)); let figure = names(&FIGURE_TAGS) .then(|| FIGURE_VALUES.get(crate::panels::opentype::figure(&f))) .flatten() @@ -214,7 +227,7 @@ fn opentype_fields(features: Option<&Value>) -> Map { fields.insert("ot_position".into(), position.map_or(Value::Null, |v| json!(v))); let sets: Vec = vectorcraft_text::EXTRA_FEATURES[..20] .iter() - .filter_map(|set| tags.iter().rev().find(|t| bare(t) == *set).map(|t| if t.starts_with('-') { format!("-{set}") } else { set.to_string() })) + .filter_map(|set| tags.iter().rev().find(|t| bare(t) == *set).map(|t| if tag_on(t) { set.to_string() } else { format!("-{set}") })) .collect(); fields.insert("ot_sets".into(), if sets.is_empty() { Value::Null } else { json!(sets) }); fields @@ -492,9 +505,18 @@ fn opentype_attrs(d: &Dialog, original: &Map, original_fields: &M } if !unchanged("ot_sets") { let sets = &vectorcraft_text::EXTRA_FEATURES[..20]; + // A set still on keeps the value its tag gives (`ss01=2`). + let before: Vec = tags.iter().filter(|t| sets.contains(&bare(t))).cloned().collect(); tags.retain(|t| !sets.contains(&bare(t))); let listed = d.fields.get("ot_sets").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str); - tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(str::to_string)); + tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(|t| { + let kept = before + .iter() + .rev() + .find(|b| bare(b) == bare(t)) + .filter(|b| !t.starts_with('-') && vectorcraft_text::OtFeatures::known_tag(b) && tag_on(b)); + kept.map_or_else(|| t.to_string(), Clone::clone) + })); } if !tags.is_empty() { attrs.insert("features".into(), json!(tags)); @@ -1724,6 +1746,21 @@ mod tests { assert_eq!(edited_attrs(&d).unwrap(), Map::new()); } + #[test] + fn a_stored_zero_value_reads_as_off_and_turning_it_on_saves_it() { + let mut d = dialog_for(json!({"features": ["ss01=0", "salt=0", "ss02=2"]})); + assert_eq!(d.fields["ot_salt"], json!(false)); + assert_eq!(d.fields["ot_sets"], json!(["-ss01", "ss02"])); + d.fields.insert("ot_sets".into(), json!(["ss01", "ss02"])); + d.fields.insert("ot_salt".into(), json!(true)); + assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["salt", "ss01", "ss02=2"])); + for last in ["ss01=0", "-ss01", "ss01=65536"] { + let mut d = dialog_for(json!({"features": ["ss01=2", last]})); + d.fields.insert("ot_sets".into(), json!(["ss01", "ss02"])); + assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["ss01", "ss02"]), "{last}: only a valid last on-value is kept"); + } + } + #[test] fn figure_tags_no_menu_entry_matches_leave_the_figure_unset() { for features in [json!(["pnum"]), json!(["lnum"]), json!(["onum", "lnum"]), json!(["tnum", "pnum"])] { diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 7c9aaf6873..2e277caea8 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -64,12 +64,12 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// Does this dialog show a Preview checkbox? Grid Repeat Options does: the public page for the /// grid options lists one, and the radial and mirror pages do not. Type on a Path Options does /// too (#5360), as public tutorials of the dialog show, and Type Tools › Release Type on a Path -/// works the same way (#5532), and so does Image Tools › Remove Background. (Cancel rolls a -/// preview back whichever dialog started it.) +/// works the same way (#5532), and so do Image Tools › Remove Background and Cutting Tools › Weed +/// Box and Lines. (Cancel rolls a preview back whichever dialog started it.) fn previews(d: &Dialog) -> bool { match d.str("__command").as_str() { "object.repeat.options" => d.fields.contains_key("gridType"), - "type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND => true, + "type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND | crate::addons::cutting::WEED => true, _ => false, } } @@ -87,6 +87,19 @@ fn call(d: &Dialog) -> (String, Value) { { o.remove("rowGutter"); } + // Weed Box and Lines: a Spacing of 0, or none, leaves the Count in use; any other Spacing is + // sent instead of the Count, so the command refuses one below 0. + if d.str("__command") == crate::addons::cutting::WEED + && let Some(o) = params.as_object_mut() + { + let zero = |s: &Value| match s { + Value::Null => true, + Value::String(t) => vectorcraft_doc::Unit::Points.parse(t).or_else(|| t.trim().parse().ok()) == Some(0.0), + v => v.as_f64() == Some(0.0), + }; + let by_count = o.get("spacing").is_none_or(zero); + o.remove(if by_count { "spacing" } else { "count" }); + } (d.str("__command"), params) } @@ -102,6 +115,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "graph.create" => &["width", "height"], "shape.flare" => &["diameter", "pathLength"], "perspective.grid.set" => &["cell", "distance"], + crate::addons::cutting::WEED => &["margin", "spacing", "gap", "weight"], "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"], "type.pathOptions" => &["spacing"], @@ -115,6 +129,7 @@ fn lengths(command: &str) -> &'static [&'static str] { /// names isn't it (the rest follow, by name). fn order(command: &str) -> &'static [&'static str] { match command { + crate::addons::cutting::WEED => &["margin", "box", "direction", "count", "spacing", "gap", "weight", "swatch"], // The public Trap options' order. "object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"], "addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"], @@ -135,6 +150,7 @@ fn choices(command: &str, key: &str) -> Option { ("text.areaOptions", "fit") => Some(AREA_FIT), ("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE), ("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN), + (crate::addons::cutting::WEED, "direction") => Some(crate::addons::cutting::WEED_DIRECTIONS), ("graph.setType", "type") => Some(GRAPH_KINDS.as_slice()), ("graph.setType", "valueAxis") => Some(GRAPH_VALUE_AXIS), ("graph.setType", "tickLength" | "rightTickLength" | "categoryTickLength") => Some(GRAPH_TICK_LENGTH), diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index caa9dfdc91..777c872494 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -882,6 +882,13 @@ fn command(d: &Dialog) -> (&'static str, Value) { p["item"] = item.unwrap_or(Value::Null); p["params"] = params; let id = d.str("__effect"); + // Each target keeps the map its own Warp draws with: the dialog's `version` is the first + // target's. + if super::warp_effect::is_warp(&id) + && let Some(Value::Object(m)) = p.get_mut("params") + { + m.remove("version"); + } // The displayed style comes from the first target. Preserve other targets' Warp IDs // unless the user deliberately chooses Style, including after a preview rollback. if super::warp_effect::is_warp(&id) && d.bool("__warp_style_changed") { diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index f537595ebc..6c9d95b601 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -239,7 +239,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> { let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?; let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None }; let bar = match &**kind { - EnvelopeKind::Warp { style, bend, h, v, horizontal } => { + EnvelopeKind::Warp { style, bend, h, v, horizontal, .. } => { Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal } } EnvelopeKind::Mesh { rows, cols, .. } => Bar::Mesh { rows: *rows, cols: *cols }, diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index c4c043b39e..63dcfa916b 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -92,9 +92,8 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0)); let area = egui::Id::unique("gradient-stop-popover"); let size = ctx.memory(|m| m.area_rect(area)).map_or(POPOVER_SIZE, |r| r.size()); - let pos = screen.unwrap_or_else(|| match app.canvas_rect.zip(app.view().copied()) { - Some((canvas, v)) => { - let xf = Xf::new(canvas, &v); + let pos = screen.unwrap_or_else(|| match app.canvas_rect.zip(app.canvas_xf()) { + Some((canvas, xf)) => { let at = xf.to_screen(chip); selection_on_screen(app, &xf).map_or(at + vec2(14.0, 14.0), |art| beside_art(at, art, size, canvas)) } diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index 5016eacd06..4595447ac2 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -92,7 +92,7 @@ impl Geometry { let state = app.session.active()?; let node = state.doc.node(*state.selection.objects.first()?)?; let bounds = node.geometric_bounds()?; - let xf = Xf::new(app.canvas_rect?, app.view()?); + let xf = app.canvas_xf()?; let options = super::revolve::light_options(d); // Both Revolves turn about their axis; the others about the centre of the art's bounds. let id = d.str("__effect"); diff --git a/crates/ui-egui/src/dialogs/simplify_slider.rs b/crates/ui-egui/src/dialogs/simplify_slider.rs index 820ab9af90..88838585ee 100644 --- a/crates/ui-egui/src/dialogs/simplify_slider.rs +++ b/crates/ui-egui/src/dialogs/simplify_slider.rs @@ -202,8 +202,7 @@ fn bounds(app: &mut VectorcraftApp, d: &mut Dialog) -> Option { fn place(app: &mut VectorcraftApp, ctx: &egui::Context, id: egui::Id, bounds: Option) -> Pos2 { let size = egui::AreaState::load(ctx, id).and_then(|s| s.size).unwrap_or(vec2(270.0, 42.0)); let screen = ctx.content_rect(); - let Some((rect, view)) = app.canvas_rect.zip(app.view().copied()) else { return screen.center() - size / 2.0 }; - let xf = crate::canvas::Xf::new(rect, &view); + let Some((rect, xf)) = app.canvas_rect.zip(app.canvas_xf()) else { return screen.center() - size / 2.0 }; let under = match bounds { Some(b) => { let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 97e2b16ee7..7ae09dbdf6 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -341,6 +341,32 @@ fn edits_after_ok_do_not_reach_the_files() { let _ = std::fs::remove_dir_all(&dir); } +/// An export into the folder its export preset keeps, with no folder of its own, is written in the +/// background as one into a folder it names, and the status bar names that folder. +#[test] +fn an_export_into_a_presets_folder_writes_in_the_background() { + let gate = Gate::default(); + let (mut app, _, files) = app_with_worker(1, &gate); + let dir = temp_folder("preset-folder"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "svg"}], "folder": folder}); + app.run("document.exportPresets.save", json!({"name": "Kept", "settings": settings})).unwrap(); + let p = json!({"exportPreset": "Kept"}); + background_export(&mut app, false); + let want = on_disk(&app.run("file.exportForScreens", p.clone()).unwrap()); + assert_eq!(want.len(), 1); + let status = app.ui.status.clone(); + assert_eq!(status, format!("Exported 1 file to {folder}")); + std::fs::remove_dir_all(&dir).unwrap(); + background_export(&mut app, true); + assert_eq!(app.run("file.exportForScreens", p).unwrap()["background"], true); + assert!(written(&files).is_empty(), "nothing written yet"); + gate.open(); + background::wait_all(&mut app); + assert_eq!((files.lock().unwrap().clone(), app.ui.status.clone()), (want, status)); + let _ = std::fs::remove_dir_all(&dir); +} + /// Cancel stops the export before its next file; the file being written when it came stays. #[test] fn cancel_stops_the_export_before_its_next_file() { diff --git a/crates/ui-egui/src/dialogs/tests_warp.rs b/crates/ui-egui/src/dialogs/tests_warp.rs index e7cbfcc5b7..21d3e8e1a4 100644 --- a/crates/ui-egui/src/dialogs/tests_warp.rs +++ b/crates/ui-egui/src/dialogs/tests_warp.rs @@ -616,3 +616,33 @@ fn warp_options_style_keyboard_enter_does_not_accept_dialog() { assert_eq!(app.session.doc().unwrap().doc, moved); } } + +/// Warp Options editing the Warps of two objects, one new and one saved without a map version, +/// changes the Bend of both and keeps each one's own `version`. +#[test] +fn warp_options_edit_keeps_each_targets_map_version() { + let (mut app, first) = app(); + app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap(); + let second = NodeId(app.run("shape.rectangle", json!({"x": 230, "y": 60, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap()); + app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap(); + // As a file saved before the version existed gives it. + let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + if let Some(Value::Object(p)) = doc.node_mut(second).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) { + p.remove("version"); + } + app.run("select.set", json!({"ids": [first.0, second.0]})).unwrap(); + app.run("effect.dialog", json!({"effect": "warp.arc"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, effect::EXISTS); + let ctx = context(); + settle(&mut app, &ctx); + click_text(&mut app, &ctx, "Edit"); + settle(&mut app, &ctx); + make_warp_edit(&mut app, &ctx, "bend"); + click_text(&mut app, &ctx, "OK"); + assert!(app.ui.dialog.is_none()); + let doc = app.session.doc().unwrap().doc.clone(); + let params = |id: NodeId| doc.node(id).unwrap().appearance.effects[0].params.clone(); + assert_eq!((¶ms(first)["bend"], ¶ms(first)["version"]), (&json!(25.0), &json!(vectorcraft_doc::live::WARP_NET))); + assert_eq!(params(second)["bend"], json!(25.0)); + assert!(params(second).get("version").is_none(), "{:?}", params(second)); +} diff --git a/crates/ui-egui/src/dialogs/warp_effect.rs b/crates/ui-egui/src/dialogs/warp_effect.rs index ff530fbdf2..e8ef2bb6b7 100644 --- a/crates/ui-egui/src/dialogs/warp_effect.rs +++ b/crates/ui-egui/src/dialogs/warp_effect.rs @@ -1,7 +1,7 @@ //! Effect > Warp controls. The style remains an effect ID; Envelope Warp has a different schema. use egui::{Pos2, Rect, Stroke, vec2}; use serde_json::{Map, Value, json}; -use vectorcraft_effects::{WARP_STYLES, WarpStyle, warp_point}; +use vectorcraft_effects::{WARP_STYLES, WarpNet, WarpStyle}; use super::form; use crate::state::Dialog; @@ -38,6 +38,8 @@ fn shown(label: &str) -> String { fn style_preview(ui: &mut egui::Ui, suffix: &str) { let (rect, _) = ui.allocate_exact_size(vec2(30.0, 22.0), egui::Sense::hover()); let Some(style) = WarpStyle::from_id(suffix) else { return }; + // The map new warps take, on a box 0.55 times as high as it is wide. + let net = WarpNet::new(style, 0.45, 0.0, 0.0, 2.0, 1.1, false); let mut lines = vec![]; let mut bounds = Rect::NOTHING; for vertical in [false, true] { @@ -46,8 +48,8 @@ fn style_preview(ui: &mut egui::Ui, suffix: &str) { .map(|i| { let along = -1.0 + f64::from(i) / 8.0; let (x, y) = if vertical { (fixed, along) } else { (along, fixed) }; - let (x, y) = warp_point(style, 0.45, 0.0, 0.0, 0.55, x, y); - let p = Pos2::new(x as f32, y as f32); + let q = net.at((x + 1.0) / 2.0, (y + 1.0) / 2.0); + let p = Pos2::new(q.x as f32, q.y as f32); bounds.extend_with(p); p }) diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index a054794379..259408711c 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -21,12 +21,13 @@ fn the_thai_filter_lists_fonts_with_thai() { /// stay clear of it. #[test] fn the_scroll_bar_leaves_the_stars_clear() { - // Every family starred, so each row draws its star. + // Every family starred, so each row draws its star. Tests running alongside add fonts and + // composite fonts to the process-wide database, which the menu lists too: the ★ filter keeps + // the rows to the families starred here. let all = vectorcraft_text::FontDb::global().families(); - let families = all.len(); let ctx = egui::Context::default(); let area = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 160.0)); - let mut st = super::MenuState::default(); + let mut st = super::MenuState { favorites_only: true, ..Default::default() }; let look = super::MenuLook { samples: false, favorites: &all, ..Default::default() }; let mut stars = vec![]; for _ in 0..3 { @@ -45,6 +46,7 @@ fn the_scroll_bar_leaves_the_stars_clear() { }) .collect(); } + let families = st.rows.len(); assert!(!stars.is_empty() || families == 0, "{families} families, no row stars drawn"); // The rows don't fill the list without enough families to scroll, and then there is no bar. if families as f32 * look.row > area.height() { @@ -62,6 +64,13 @@ fn family(name: &str) -> Row { Row::Family(name.to_string()) } +/// The families the font menu lists that stay listed, in its order: the menu's list without the +/// composite fonts at its top, which tests running alongside define and remove in the process-wide +/// database. +fn lasting_families() -> Vec { + vectorcraft_text::FontDb::global().family_list().iter().filter(|f| !f.starts_with('.')).cloned().collect() +} + #[test] fn recent_fonts_are_first_available_unique_and_expandable() { let names: Vec<_> = ["Alpha", "Bravo", "Charlie"].map(str::to_string).into(); @@ -86,7 +95,7 @@ fn recent_fonts_are_first_available_unique_and_expandable() { #[test] fn recent_section_rebuilds_and_arrows_skip_its_headings() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); if names.len() < 3 { return; } @@ -135,7 +144,7 @@ fn recent_section_rebuilds_and_arrows_skip_its_headings() { #[test] fn recent_fonts_obey_search_and_favorite_filters() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); if names.len() < 3 { return; } @@ -176,7 +185,7 @@ fn recent_fonts_obey_search_and_favorite_filters() { #[test] fn opening_on_another_font_keeps_the_recent_section_visible() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); let Some(current) = names.last() else { return }; let recent = vec![names[0].clone()]; let ctx = egui::Context::default(); @@ -244,7 +253,7 @@ fn the_wheel_steps_one_family_per_notch_and_stops_at_the_ends() { /// others in the database's order, under the favourites (and other) filters. #[test] fn the_wheel_follows_the_menus_order_and_filters() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); if names.len() < 3 { return; } @@ -770,8 +779,9 @@ fn the_variable_filter_lists_only_variable_families() { assert_eq!(frame(&mut st, vec![], &favorites).0, [TWO_AXIS_FAMILY]); st.favorites_only = false; // Off again: every family is listed. + let lasting = lasting_families(); let again = click(&mut st, &favorites); assert!(!st.variable_only); - assert_eq!(again, frame(&mut st, vec![], &[]).0); + assert!(lasting.iter().all(|f| has(&again, f)), "{again:?}"); assert!(has(&again, TWO_AXIS_FAMILY) && has(&again, "Source Sans 3") && has(&again, "JetBrains Mono"), "{again:?}"); } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e666fcc8c6..4f7136bdad 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -879,6 +879,17 @@ font-technology Variable Променливи The selected character's font is not available. Шрифтът на избрания знак не е наличен. No alternates for the selected character. Няма алтернативи за избрания знак. Double-click to replace Двойно щракване за замяна + Select one character with the Type tool first. Първо изберете един знак с инструмента „Текст“. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} е глиф на {font}, а не на шрифта, с който е зададен избраният текст ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} принадлежи на {base} (U+{base_code}) {place}; избраният от вас знак е {sel} (U+{code}). + at the start of a word в началото на дума + in the middle of a word в средата на дума + at the end of a word в края на дума + standing alone когато стои сам + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не е алтернатива на {sel} (U+{code}) там, където е в думата си. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Двойно щракване за вмъкване на {base} (U+{code}): представителната форма не се свързва, буквата се свързва. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Вмъкнато е {base} (U+{code}) вместо представителна форма: шрифтът я свързва със съседите според позицията. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Нито една OpenType функция не рисува този глиф тук, затова той заменя глифа на знака и може да не се свързва със съседните знаци. Double-click to insert Двойно щракване за вмъкване Double-click to rename Двойно щракване за преименуване Double-click to target the contents Двойно щракване за насочване към съдържанието @@ -1924,6 +1935,8 @@ font-technology Variable Променливи Metallic Gradients Метални разградки Meters Метри Method: Метод: + Pad Удължаване + Repeats Повторения Millimeters Милиметри Minify Минимизиране на код Minimal Минимално @@ -3325,6 +3338,9 @@ digits Malayalam Малаялам Split Path Разделяне на път spot петно Spot Color Спот цвят + Spot Color Objects Обекти със спот цветове + Pattern Objects Обекти с мостри + Gradient Objects Обекти с градиенти Spot Colors Спот цветове Spot Colors… Спот цветове… Spread: Разпространение: @@ -3634,6 +3650,8 @@ digits Malayalam Малаялам Type on a Path Текст по пътя Type on a Path Options Опции за текст по пътя Type on a Path Options… Опции за текст по път… + Center on Top Центриране горе + Center on Bottom Центриране долу Type on a Path Tool Инструмент „Текст по път“ Type Orientation Ориентация на текста Type stays text, and every stroke is a line with its lineweight and dashes. Текстът остава текст, а всеки щрих е линия с дебелина и чертички. @@ -4245,6 +4263,7 @@ axis Both И двете @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` не е цвят като "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: най-много {MAX_TABLE} цвята @msg command `{0}` is not available right now: {1} команда `{0}` не е налична в момента: {1} +@msg command `{id}` is not available right now команда `{id}` не е налична в момента @msg compound paths can only contain paths съставните пътища могат да съдържат само пътища @msg compressed (RLE) bitmaps can't be read yet компресираните (RLE) битови карти все още не могат да бъдат четени @msg damaged повреден @@ -5858,6 +5877,7 @@ theme Appearance Външен вид Ruby… Руби… Ruby Руби Geometric Guides Геометрични водачи + Optical Effects Оптични ефекти Geometric Patterns Геометрични мостри Grit Textures Зърнести текстури Ease In Плавно начало @@ -6072,6 +6092,11 @@ axis Y Axis Y ос Grass Fan Ветрило за трева Pattern Borders Бордюри с мостри Scatter Shapes Разсеяни фигури + Dry Media Сухи материали + Chalk Grain Тебеширено зърно + Graphite Pencil Графитен молив + Wax Crayon Восъчен пастел + Charcoal Dust Въглен прах Save Brush Library… Запази библиотека с четки… @msg no empty artboards to delete няма празни работни области за изтриване Scale Presets Пресети за мащаб @@ -6676,4 +6701,9 @@ breakText Glyphs Глифи Stencil Bridges… Мостчета на шаблона… Stencil Bridges Мостчета на шаблона Shortest Crossing Най-късо пресичане + Weed Box and Lines… Рамка и линии за почистване… + Weed Box and Lines Рамка и линии за почистване + Margin Поле + Box Рамка + Swatch Образец Direction Посока diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 2381deb135..0e7dd028fd 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -398,6 +398,8 @@ Stair Step Stepenice Gravity Gravitacija Type on a Path Options… Opcije teksta na putanji… + Center on Top Centriraj na vrhu + Center on Bottom Centriraj na dnu Threaded Text Povezani tekst Create Kreiraj Release Selection Oslobodi odabir @@ -1676,6 +1678,17 @@ digits Malayalam Malajalam The selected character's font is not available. Font odabranog znaka nije dostupan. No alternates for the selected character. Nema alternativa za odabrani znak. Double-click to replace Dvostruki klik za zamjenu + Select one character with the Type tool first. Prvo odaberite jedan znak alatom za tekst. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glif fonta {font}, a ne fonta kojim je postavljen odabrani tekst ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pripada znaku {base} (U+{base_code}) {place}; vaš odabir je {sel} (U+{code}). + at the start of a word na početku riječi + in the middle of a word usred riječi + at the end of a word na kraju riječi + standing alone kad stoji sam + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} nije alternativa znaku {sel} (U+{code}) onako kako stoji u svojoj riječi. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dvostruki klik za umetanje znaka {base} (U+{code}): oblik za prikaz se ne spaja, slovo se spaja. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Umetnut je znak {base} (U+{code}) umjesto oblika za prikaz: font ga spaja sa susjedima prema položaju. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nijedna OpenType funkcija ne crta ovaj glif ovdje, pa on zamjenjuje glif znaka i možda se neće spajati sa susjednim znakovima. Double-click to insert Dvostruki klik za umetanje Entire Font Cijeli font glyph {id} glif {id} @@ -1834,6 +1847,8 @@ digits Malayalam Malajalam Save Image Trace Preset Sačuvaj unaprijed postavljeno praćenje slike Delete Image Trace Preset Izbriši unaprijed postavljeno praćenje slike Method: Metoda: + Pad Produži + Repeats Broj ponavljanja Mode: Način: Noise Šum Create: Kreiraj: @@ -3908,6 +3923,9 @@ theme Appearance Izgled Select All Unused Odaberi sve nekorišteno Sort by Kind Sortiraj po vrsti Sort by Name Sortiraj po nazivu + Spot Color Objects Objekti sa spot bojama + Pattern Objects Objekti s uzorcima + Gradient Objects Objekti s gradijentima Spot Colors Spot boje Spot Colors (0) Spot boje (0) Swatch Options Opcije uzorka boje @@ -4604,6 +4622,8 @@ imageTrace Outline Obris @msg the Smooth slider is not open klizač Izgladi nije otvoren @msg the screen color sampler is already open uzorkivač boja s ekrana je već otvoren @msg this system has no screen color sampler ovaj sistem nema uzorkivač boja s ekrana +@msg the screen color sampler did not answer uzorkivač boja s ekrana nije odgovorio +@msg the screen color sampler failed uzorkivač boja s ekrana nije uspio @msg bad colour `{hex}` (#rrggbb) loša boja `{hex}` (#rrggbb) @msg bad page tree loše stablo stranica @msg bad shortcut loša prečica @@ -4633,6 +4653,7 @@ imageTrace Outline Obris @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` nije boja poput "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: najviše {MAX_TABLE} boja @msg command `{0}` is not available right now: {1} naredba `{0}` trenutno nije dostupna: {1} +@msg command `{id}` is not available right now naredba `{id}` trenutno nije dostupna @msg compound paths can only contain paths složene putanje mogu sadržavati samo putanje @msg compressed (RLE) bitmaps can't be read yet komprimirane (RLE) bitmape se još ne mogu čitati @msg damaged oštećeno @@ -5875,6 +5896,7 @@ mark-style Japanese Japanski Ruby… Ruby… Ruby Ruby Geometric Guides Geometrijske vodilice + Optical Effects Optički efekti Geometric Patterns Geometrijski uzorci Grit Textures Zrnaste teksture Ease In Ublaži ulaz @@ -6123,6 +6145,11 @@ axis Y Axis Y osa Grass Fan Lepeza za travu Pattern Borders Ivice od uzoraka Scatter Shapes Raspršeni oblici + Dry Media Suhi mediji + Chalk Grain Zrno krede + Graphite Pencil Grafitna olovka + Wax Crayon Voštana bojica + Charcoal Dust Ugljena prašina Save Brush Library… Sačuvaj biblioteku četkica… @msg `{_1}` has no brushes to apply `{_1}` nema četkica za primjenu @msg missing `library` (see brush.libraries) nedostaje `library` (pogledajte brush.libraries) @@ -6728,4 +6755,9 @@ shape Open Otvoren Stencil Bridges… Mostići šablona… Stencil Bridges Mostići šablona Shortest Crossing Najkraći prijelaz + Weed Box and Lines… Okvir i linije za čišćenje… + Weed Box and Lines Okvir i linije za čišćenje + Margin Margina + Box Okvir + Swatch Uzorak boje Direction Smjer diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6dfad587..2cac71874d 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1288,6 +1288,17 @@ antiAlias Sharp Nítid The selected character's font is not available. El tipus de lletra del caràcter seleccionat no està disponible. No alternates for the selected character. No hi ha alternatives per al caràcter seleccionat. Double-click to replace Feu doble clic per substituir + Select one character with the Type tool first. Selecciona primer un caràcter amb l’eina de text. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} és un glif de {font}, no del tipus de lletra amb què està escrit el text seleccionat ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertany a {base} (U+{base_code}) {place}; la teva selecció és {sel} (U+{code}). + at the start of a word a l’inici d’una paraula + in the middle of a word al mig d’una paraula + at the end of a word al final d’una paraula + standing alone quan va sol + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} no és una alternativa de {sel} (U+{code}) tal com es troba a la seva paraula. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Feu doble clic per inserir {base} (U+{code}): una forma de presentació no s’uneix, la lletra sí. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. S’ha inserit {base} (U+{code}) en lloc d’una forma de presentació: el tipus de lletra l’uneix als veïns segons la posició. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Cap funció OpenType dibuixa aquest glif aquí, de manera que substitueix el glif del caràcter i pot no unir-se als caràcters veïns. Double-click to insert Feu doble clic per inserir Double-click to rename Feu doble clic per canviar el nom Double-click to target the contents Feu doble clic per apuntar al contingut @@ -2531,6 +2542,8 @@ digits Malayalam Malaiàlam Meters Metres Method Mètode Method: Mètode: + Pad Allarga + Repeats Repeticions Mezzotint Mezzotinta Mezzotint… Mezzotinta… Millimeters Mil·límetres @@ -2927,12 +2940,17 @@ digits Malayalam Malaiàlam Opens network connections Obre connexions de xarxa Open… Obre… Optical Òptic + Optical Effects Efectes òptics Optical Margin Alignment Alineació òptica de marges Dynamic Text Text dinàmic Dynamic Text Presets Valors predefinits de text dinàmic Arch Volta @msg select point type or type on a path seleccioneu text de punt o text sobre un traçat @msg type on a path in a thread cannot use Dynamic Text: {frames} el text sobre un traçat d’un text enllaçat no pot fer servir el text dinàmic: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} el text sobre un traçat obert o compost no es pot centrar a dalt o a baix: {open} +@msg type on a path with no length or no height cannot be centered: {flat} el text sobre un traçat sense longitud o sense alçada no es pot centrar: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} el text sobre un traçat d’un text enllaçat no es pot centrar a dalt o a baix: {frames} +@msg select type on a closed path seleccioneu text sobre un traçat tancat Optimize for fast web view Optimitza per a la visualització web ràpida Optimized Optimitzat Options Opcions @@ -3863,6 +3881,11 @@ digits Malayalam Malaiàlam Scatter Brush Options Opcions del pinzell de dispersió Scatter Graph Tool Eina de gràfic de dispersió Scatter Shapes Formes de dispersió + Dry Media Tècniques seques + Chalk Grain Gra de guix + Graphite Pencil Llapis de grafit + Wax Crayon Cera de colors + Charcoal Dust Pols de carbó Scatter: Dispersió: Scissors Tisores Scissors Tool Eina de tisores @@ -4304,6 +4327,9 @@ digits Malayalam Malaiàlam Sponge… Esponja… spot directe Spot Color Color directe + Spot Color Objects Objectes amb colors directes + Pattern Objects Objectes amb motius + Gradient Objects Objectes amb degradats Spot Colors Colors directes Spot Colors (0) Colors directes (0) Spot Colors… Colors directes… @@ -4711,6 +4737,8 @@ digits Malayalam Malaiàlam Type on a Path Text sobre un traçat Type on a Path Options Opcions de text sobre un traçat Type on a Path Options… Opcions de text sobre un traçat… + Center on Top Centra a dalt + Center on Bottom Centra a baix Type on a Path Tool Eina de text sobre un traçat Type Optimized Optimitzat per al text Type Orientation Orientació del text @@ -5094,6 +5122,9 @@ digits Malayalam Malaiàlam @msg `colors` must be an array of colors, not {v} `colors` ha de ser una matriu de colors, no {v} @msg `library` is a library id or name (see {list}) `library` és un identificador o un nom de biblioteca (vegeu {list}) @msg `method` must be classic or perceptual `method` ha de ser classic o perceptual +@msg `spread` must be pad, repeat or reflect `spread` ha de ser pad, repeat o reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` ha de ser un nombre enter d’1 a {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` s’aplica als degradats lineals, radials, cònics i de rombe @msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` i `maxAnchors` han de ser nombres enters @msg `mode` applies to freeform gradients `mode` s’aplica als degradats de forma lliure @msg a negative `aspect` applies to conic gradients un `aspect` negatiu s’aplica als degradats cònics @@ -5297,6 +5328,7 @@ digits Malayalam Malaiàlam @msg column must be true or a number from 1 column ha de ser true o un nombre a partir d’1 @msg Combine Slices: the slices have no bounds Combina els talls: els talls no tenen límits @msg command `{0}` is not available right now: {1} l’ordre `{0}` no està disponible ara mateix: {1} +@msg command `{id}` is not available right now l’ordre `{id}` no està disponible ara mateix @msg compound paths can only contain paths els traçats compostos només poden contenir traçats @msg compressed (RLE) bitmaps can't be read yet encara no es poden llegir els mapes de bits comprimits (RLE) @msg control must be fillStroke, coloring, drawMode or screenMode control ha de ser fillStroke, coloring, drawMode o screenMode @@ -6019,6 +6051,8 @@ digits Malayalam Malaiàlam @msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it aquest fitxer PostScript no es pot llegir ({why}): desa’l com a PDF o SVG a l’aplicació que l’ha creat @msg This shortcut file is in a form VectorCraft can't read. Aquest fitxer de dreceres té un format que VectorCraft no pot llegir. @msg this system has no screen color sampler aquest sistema no té cap mostrejador de color de pantalla +@msg the screen color sampler did not answer el mostrejador de color de pantalla no ha respost +@msg the screen color sampler failed el mostrejador de color de pantalla ha fallat @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset aquesta traça crearia més de {max} punts d’ancoratge, més dels que el document pot mostrar amb fluïdesa: augmenta el Soroll, redueix Traçats o Colors, o tria un preajust de fidelitat més baixa @msg thumbnail: no page {n} (the file has 1) miniatura: no hi ha la pàgina {n} (el fitxer en té 1) @msg tintReduction must be from 0 to 100 % tintReduction ha de ser d’entre 0 i 100 % @@ -6972,3 +7006,8 @@ breakText Glyphs Glifs @msg Some islands could not be joined to the area around the art Algunes illes no s’han pogut unir a l’àrea que envolta la il·lustració @msg `width` must be from {lo} to {hi} pt `width` ha d’estar entre {lo} i {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` ha de ser un nombre enter d’1 a {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Caixa i línies de pelat… + Weed Box and Lines Caixa i línies de pelat + Margin Marge + Box Caixa + Swatch Mostra diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 00de3e7057..6a74590c93 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -405,6 +405,8 @@ Stair Step Schody Gravity Gravitace Type on a Path Options… Možnosti textu na cestě… + Center on Top Vystředit nahoře + Center on Bottom Vystředit dole Threaded Text Zřetězený text Create Vytvořit Release Selection Uvolnit výběr @@ -425,6 +427,10 @@ Arch Klenba @msg select point type or type on a path vyberte bodový text nebo text na cestě @msg type on a path in a thread cannot use Dynamic Text: {frames} text na cestě ve zřetězeném textu nemůže používat dynamický text: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} text na otevřené nebo složené cestě nelze vystředit nahoře ani dole: {open} +@msg type on a path with no length or no height cannot be centered: {flat} text na cestě bez délky nebo bez výšky nelze vystředit: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} text na cestě ve zřetězeném textu nelze vystředit nahoře ani dole: {frames} +@msg select type on a closed path vyberte text na uzavřené cestě Fill with Placeholder Text Vyplnit zástupným textem Insert Inline Symbol Vložit symbol do textu Show Hidden Characters Zobrazit skryté znaky @@ -1701,6 +1707,17 @@ digits Malayalam Malajálamské The selected character's font is not available. Písmo vybraného znaku není k dispozici. No alternates for the selected character. Vybraný znak nemá žádné alternativy. Double-click to replace Dvojklikem nahradíte + Select one character with the Type tool first. Nejprve nástrojem Text vyberte jeden znak. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glyf písma {font}, ne písma, kterým je vybraný text nastaven ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} patří znaku {base} (U+{base_code}) {place}; váš výběr je {sel} (U+{code}). + at the start of a word na začátku slova + in the middle of a word uprostřed slova + at the end of a word na konci slova + standing alone když stojí samostatně + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} není alternativou znaku {sel} (U+{code}) tak, jak stojí ve svém slově. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dvojklikem vložíte {base} (U+{code}): prezentační tvar se nespojuje, písmeno ano. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Vloženo {base} (U+{code}) místo prezentačního tvaru: písmo ho podle polohy spojí se sousedy. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Žádná funkce OpenType tento glyf zde nekreslí, proto nahradí glyf znaku a nemusí se spojit se sousedními znaky. Double-click to insert Dvojklikem vložíte Entire Font Celé písmo glyph {id} glyf {id} @@ -1861,6 +1878,8 @@ digits Malayalam Malajálamské Save Image Trace Preset Uložit přednastavení vektorizace obrazu Delete Image Trace Preset Odstranit přednastavení vektorizace obrazu Method: Metoda: + Pad Prodloužit + Repeats Počet opakování Mode: Režim: Noise Šum Create: Vytvořit: @@ -3960,6 +3979,9 @@ theme Appearance Motiv Select All Unused Vybrat všechny nepoužité Sort by Kind Seřadit podle druhu Sort by Name Seřadit podle názvu + Spot Color Objects Objekty s přímými barvami + Pattern Objects Objekty se vzorky + Gradient Objects Objekty s přechody Spot Colors Přímé barvy Spot Colors (0) Přímé barvy (0) Swatch Options Možnosti položky vzorníku @@ -4681,6 +4703,8 @@ imageTrace Outline Obrys @msg the Smooth slider is not open Posuvník Vyhladit není otevřený @msg the screen color sampler is already open nástroj pro nabírání barvy z obrazovky už je otevřený @msg this system has no screen color sampler tento systém nemá nástroj pro nabírání barvy z obrazovky +@msg the screen color sampler did not answer nástroj pro nabírání barvy z obrazovky neodpověděl +@msg the screen color sampler failed nástroj pro nabírání barvy z obrazovky selhal @msg bad colour `{hex}` (#rrggbb) chybná barva `{hex}` (#rrggbb) @msg bad page tree chybný strom stránek @msg bad shortcut chybná klávesová zkratka @@ -4710,6 +4734,7 @@ imageTrace Outline Obrys @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` není barva jako "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: nejvýše {MAX_TABLE} barev @msg command `{0}` is not available right now: {1} příkaz `{0}` nyní není k dispozici: {1} +@msg command `{id}` is not available right now příkaz `{id}` nyní není k dispozici @msg compound paths can only contain paths složené cesty mohou obsahovat jen cesty @msg compressed (RLE) bitmaps can't be read yet komprimované bitmapy (RLE) zatím nelze načíst @msg damaged poškozeno @@ -6040,6 +6065,7 @@ mark-style Japanese Japonské Ruby… Ruby… Ruby Ruby Geometric Guides Geometrická vodítka + Optical Effects Optické efekty Geometric Patterns Geometrické vzorky Grit Textures Zrnité textury Ease In Pozvolný začátek @@ -6082,6 +6108,9 @@ axis Y Axis osa Y @msg `dither` applies to gradient fills (paint.setFill) `dither` platí pro výplně přechodem (paint.setFill) @msg `dither` must be true or false `dither` musí být true nebo false @msg `method` must be classic or perceptual `method` musí být classic nebo perceptual +@msg `spread` must be pad, repeat or reflect `spread` musí být pad, repeat nebo reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` musí být celé číslo od 1 do {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` platí pro lineární, kruhové, kuželové a kosočtverečné přechody # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Nainstalovat „{name}“, který žádá o velký výpočetní rozpočet? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Každé spuštění může trvat až {minutes} minut a vykonat {times}krát víc práce, než smějí jiné zásuvné moduly. Běží na pozadí a můžete ho zrušit. @@ -6331,6 +6360,11 @@ axis Y Axis osa Y Grass Fan Vějíř na trávu Pattern Borders Vzorkové okraje Scatter Shapes Rozptýlené tvary + Dry Media Suché techniky + Chalk Grain Křídové zrno + Graphite Pencil Grafitová tužka + Wax Crayon Vosková pastelka + Charcoal Dust Uhlový prach Save Brush Library… Uložit knihovnu štětců… @msg `{_1}` has no brushes to apply `{_1}` nemá žádné štětce k použití @msg missing `library` (see brush.libraries) chybí `library` (viz brush.libraries) @@ -6991,3 +7025,8 @@ breakText Glyphs Glyfy @msg Some islands could not be joined to the area around the art Některé ostrůvky nebylo možné připojit k oblasti kolem kresby @msg `width` must be from {lo} to {hi} pt `width` musí být od {lo} do {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musí být celé číslo od 1 do {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Rámeček a linie pro vybírání… + Weed Box and Lines Rámeček a linie pro vybírání + Margin Okraj + Box Rámeček + Swatch Vzorek diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f48e9d6ea3..2b7d6edd5a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -366,6 +366,8 @@ Stair Step Trappetrin Gravity Tyngdekraft Type on a Path Options… Indstillinger for tekst på kurve… + Center on Top Centrer øverst + Center on Bottom Centrer nederst Threaded Text Sammenkædet tekst Create Opret Release Selection Frigør markering @@ -1611,6 +1613,17 @@ digits Malayalam Malayalam The selected character's font is not available. Skrifttypen for det markerede tegn er ikke tilgængelig. No alternates for the selected character. Ingen alternativer til det markerede tegn. Double-click to replace Dobbeltklik for at erstatte + Select one character with the Type tool first. Markér først ét tegn med tekstværktøjet. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} er et glyf i {font}, ikke i den skrifttype, den markerede tekst er sat i ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hører til {base} (U+{base_code}) {place}; din markering er {sel} (U+{code}). + at the start of a word i begyndelsen af et ord + in the middle of a word midt i et ord + at the end of a word i slutningen af et ord + standing alone når det står alene + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} er ikke et alternativ til {sel} (U+{code}), som det står i sit ord. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dobbeltklik for at indsætte {base} (U+{code}) i stedet: en præsentationsform forbindes ikke, bogstavet gør. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) er indsat i stedet for en præsentationsform: skrifttypen forbinder det med naboerne efter position. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion tegner dette glyf her, så det erstatter tegnets glyf og forbindes måske ikke med nabotegnene. Double-click to insert Dobbeltklik for at indsætte Entire Font Hele skrifttypen glyph {id} glyf {id} @@ -1692,6 +1705,8 @@ digits Malayalam Malayalam Save Image Trace Preset Gem forudindstilling til billedsporing Delete Image Trace Preset Slet forudindstilling til billedsporing Method: Metode: + Pad Forlæng + Repeats Gentagelser Mode: Tilstand: Noise Støj Create: Opret: @@ -3683,6 +3698,9 @@ theme Appearance Udseende Select All Unused Markér alle ubrugte Sort by Kind Sortér efter type Sort by Name Sortér efter navn + Spot Color Objects Objekter med spotfarver + Pattern Objects Objekter med mønstre + Gradient Objects Objekter med gradienter Spot Colors Spotfarver Spot Colors (0) Spotfarver (0) Swatch Options Indstillinger for farveprøve @@ -4357,6 +4375,7 @@ imageTrace Outline Kontur @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` er ikke en farve som "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: højst {MAX_TABLE} farver @msg command `{0}` is not available right now: {1} kommandoen `{0}` er ikke tilgængelig lige nu: {1} +@msg command `{id}` is not available right now kommandoen `{id}` er ikke tilgængelig lige nu @msg compound paths can only contain paths sammensatte kurver kan kun indeholde kurver @msg compressed (RLE) bitmaps can't be read yet komprimerede (RLE) bitmaps kan endnu ikke læses @msg damaged beskadiget @@ -5513,6 +5532,8 @@ mark-style Japanese Japansk @msg the Color Picker is not open farvevælgeren er ikke åben @msg the screen color sampler is already open skærmfarvesampleren er allerede åben @msg this system has no screen color sampler dette system har ingen skærmfarvesampler +@msg the screen color sampler did not answer skærmfarvesampleren svarede ikke +@msg the screen color sampler failed skærmfarvesampleren mislykkedes swatch [None] [Ingen] swatch [Registration] [Passning] swatch White Hvid @@ -5873,6 +5894,7 @@ swatch Bright Violet Kraftig violet Ruby… Ruby… Ruby Ruby Geometric Guides Geometriske hjælpelinjer + Optical Effects Optiske effekter Geometric Patterns Geometriske mønstre Grit Textures Kornede teksturer Ease In Langsom start @@ -6087,6 +6109,11 @@ axis Y Axis Y-akse Grass Fan Græsvifte Pattern Borders Mønsterborter Scatter Shapes Spredte former + Dry Media Tørre medier + Chalk Grain Kridtkorn + Graphite Pencil Grafitblyant + Wax Crayon Vokskridt + Charcoal Dust Kulstøv Save Brush Library… Gem penselbibliotek… @msg no empty artboards to delete ingen tomme tegneflader at slette Scale Presets Skaleringsforudindstillinger @@ -6690,4 +6717,9 @@ breakText Glyphs Glyffer Stencil Bridges… Stencilbroer… Stencil Bridges Stencilbroer Shortest Crossing Korteste krydsning + Weed Box and Lines… Lugeramme og lugelinjer… + Weed Box and Lines Lugeramme og lugelinjer + Margin Margen + Box Ramme + Swatch Farveprøve Direction Retning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0de0dce0b9..bedf2af395 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -881,6 +881,17 @@ font-technology Variable Variabel The selected character's font is not available. Die Schrift des ausgewählten Zeichens ist nicht verfügbar. No alternates for the selected character. Keine Alternativen für das ausgewählte Zeichen. Double-click to replace Doppelklicken zum Ersetzen + Select one character with the Type tool first. Wählen Sie zuerst mit dem Text-Werkzeug ein Zeichen aus. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} ist eine Glyphe von {font}, nicht der Schrift, in der der ausgewählte Text gesetzt ist ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} gehört zu {base} (U+{base_code}) {place}; Ihre Auswahl ist {sel} (U+{code}). + at the start of a word am Wortanfang + in the middle of a word in der Wortmitte + at the end of a word am Wortende + standing alone allein stehend + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} ist keine Alternative zu {sel} (U+{code}), so wie es in seinem Wort steht. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Doppelklicken, um stattdessen {base} (U+{code}) einzufügen: Eine Darstellungsform verbindet sich nicht, der Buchstabe schon. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) statt einer Darstellungsform eingefügt: Die Schrift verbindet es je nach Position mit den Nachbarn. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Keine OpenType-Funktion zeichnet diese Glyphe hier, daher ersetzt sie die Glyphe des Zeichens und verbindet sich möglicherweise nicht mit den Nachbarzeichen. Double-click to insert Doppelklicken zum Einfügen Double-click to rename Doppelklicken zum Umbenennen Double-click to target the contents Doppelklicken, um den Inhalt als Ziel auszuwählen @@ -2000,6 +2011,8 @@ file-filter Templates Vorlagen Metallic Gradients Metallic-Verläufe Meters Meter Method: Methode: + Pad Fortsetzen + Repeats Wiederholungen Millimeters Millimeter Minify Minifizieren Minimal Minimal @@ -2322,6 +2335,10 @@ file-filter Templates Vorlagen Arch Rundbogen @msg select point type or type on a path wählen Sie Punkttext oder Pfadtext aus @msg type on a path in a thread cannot use Dynamic Text: {frames} Pfadtext in verkettetem Text kann keinen dynamischen Text verwenden: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} Pfadtext auf einem offenen oder zusammengesetzten Pfad lässt sich nicht oben oder unten zentrieren: {open} +@msg type on a path with no length or no height cannot be centered: {flat} Pfadtext auf einem Pfad ohne Länge oder ohne Höhe lässt sich nicht zentrieren: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} Pfadtext in verkettetem Text lässt sich nicht oben oder unten zentrieren: {frames} +@msg select type on a closed path wählen Sie Pfadtext auf einem geschlossenen Pfad aus Optimize for fast web view Für schnelle Webanzeige optimieren Optimized Optimiert Options Optionen @@ -3384,6 +3401,9 @@ effect Points Punkte Split Path Pfad teilen spot Vollton Spot Color Volltonfarbe + Spot Color Objects Objekte mit Volltonfarben + Pattern Objects Objekte mit Mustern + Gradient Objects Objekte mit Verläufen Spot Colors Volltonfarben Spot Colors… Volltonfarben… Spread: Ausbreitung: @@ -3677,6 +3697,8 @@ effect Points Punkte Type on a Path Pfadtext Type on a Path Options Pfadtextoptionen Type on a Path Options… Pfadtextoptionen… + Center on Top Oben zentrieren + Center on Bottom Unten zentrieren Type on a Path Tool Pfadtext-Werkzeug Type Orientation Textrichtung Type stays text, and every stroke is a line with its lineweight and dashes. Text bleibt Text, und jede Kontur ist eine Linie mit ihrer Linienstärke und ihren Strichen. @@ -4296,6 +4318,8 @@ theme Appearance Erscheinungsbild @msg the Smooth slider is not open der Regler „Glätten“ ist nicht geöffnet @msg the screen color sampler is already open die Farbaufnahme vom Bildschirm ist bereits geöffnet @msg this system has no screen color sampler dieses System hat keine Farbaufnahme vom Bildschirm +@msg the screen color sampler did not answer die Farbaufnahme vom Bildschirm hat nicht geantwortet +@msg the screen color sampler failed die Farbaufnahme vom Bildschirm ist fehlgeschlagen @msg bad colour `{hex}` (#rrggbb) ungültige Farbe `{hex}` (#rrggbb) @msg bad page tree fehlerhafter Seitenbaum @msg bad shortcut ungültiger Tastaturbefehl @@ -4325,6 +4349,7 @@ theme Appearance Erscheinungsbild @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` ist keine Farbe wie "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: höchstens {MAX_TABLE} Farben @msg command `{0}` is not available right now: {1} der Befehl `{0}` ist derzeit nicht verfügbar: {1} +@msg command `{id}` is not available right now der Befehl `{id}` ist derzeit nicht verfügbar @msg compound paths can only contain paths zusammengesetzte Pfade können nur Pfade enthalten @msg compressed (RLE) bitmaps can't be read yet komprimierte Bitmaps (RLE) können noch nicht gelesen werden @msg damaged beschädigt @@ -5629,6 +5654,7 @@ effect Soft Weich Ruby… Ruby… Ruby Ruby Geometric Guides Geometrische Hilfslinien + Optical Effects Optische Effekte Geometric Patterns Geometrische Muster Grit Textures Körnige Texturen Ease In Langsam starten @@ -5673,6 +5699,9 @@ axis Y Axis Y-Achse @msg `dither` must be true or false `dither` muss true oder false sein Perceptual Perzeptiv @msg `method` must be classic or perceptual `method` muss classic oder perceptual sein +@msg `spread` must be pad, repeat or reflect `spread` muss pad, repeat oder reflect sein +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` muss eine ganze Zahl von 1 bis {MAX_REPEATS} sein +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` gilt für lineare, radiale, konische Verläufe und Rautenverläufe # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? „{name}“ installieren, das ein großes Rechenbudget verlangt? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Jede Ausführung kann bis zu {minutes} Minuten dauern und {times}-mal so viel Arbeit leisten, wie andere Plug-ins dürfen. Sie läuft im Hintergrund, und Sie können sie abbrechen. @@ -5922,6 +5951,11 @@ axis Y Axis Y-Achse Grass Fan Grasfächer Pattern Borders Musterbordüren Scatter Shapes Streuformen + Dry Media Trockene Medien + Chalk Grain Kreidekorn + Graphite Pencil Graphitstift + Wax Crayon Wachsmalstift + Charcoal Dust Kohlestaub Save Brush Library… Pinsel-Bibliothek speichern… @msg `{_1}` has no brushes to apply `{_1}` hat keine Pinsel zum Anwenden @msg missing `library` (see brush.libraries) `library` fehlt (siehe brush.libraries) @@ -6586,3 +6620,8 @@ breakText Glyphs Glyphen @msg Some islands could not be joined to the area around the art Einige Inseln konnten nicht mit der Fläche um das Bildmaterial verbunden werden @msg `width` must be from {lo} to {hi} pt `width` muss zwischen {lo} und {hi} pt liegen @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` muss eine ganze Zahl von 1 bis {MAX_BRIDGES_PER_ISLAND} sein + Weed Box and Lines… Entgitterrahmen und -linien… + Weed Box and Lines Entgitterrahmen und -linien + Margin Rand + Box Rahmen + Swatch Farbfeld diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a75edff7a8..6a693248c0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -852,6 +852,17 @@ font-technology Variable Μεταβλητές The selected character's font is not available. Η γραμματοσειρά του επιλεγμένου χαρακτήρα δεν είναι διαθέσιμη. No alternates for the selected character. Δεν υπάρχουν εναλλακτικοί για τον επιλεγμένο χαρακτήρα. Double-click to replace Διπλό κλικ για αντικατάσταση + Select one character with the Type tool first. Επιλέξτε πρώτα έναν χαρακτήρα με το εργαλείο κειμένου. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). Ο γλύφος {glyph} ανήκει στη γραμματοσειρά {font}, όχι σε αυτήν του επιλεγμένου κειμένου ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). Ο γλύφος {glyph} ανήκει στο {base} (U+{base_code}) {place}· η επιλογή σας είναι {sel} (U+{code}). + at the start of a word στην αρχή μιας λέξης + in the middle of a word στη μέση μιας λέξης + at the end of a word στο τέλος μιας λέξης + standing alone όταν στέκεται μόνος + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. Ο γλύφος {glyph} δεν είναι εναλλακτικός του {sel} (U+{code}) όπως βρίσκεται στη λέξη του. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Διπλό κλικ για εισαγωγή του {base} (U+{code}): μια μορφή παρουσίασης δεν συνδέεται, το γράμμα συνδέεται. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Εισήχθη το {base} (U+{code}) αντί για μορφή παρουσίασης: η γραμματοσειρά το συνδέει με τους γείτονες ανάλογα με τη θέση. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Καμία λειτουργία OpenType δεν σχεδιάζει αυτόν τον γλύφο εδώ, οπότε αντικαθιστά τον γλύφο του χαρακτήρα και μπορεί να μην συνδέεται με τους γειτονικούς χαρακτήρες. Double-click to insert Διπλό κλικ για εισαγωγή Double-click to rename Διπλό κλικ για μετονομασία Double-click to target the contents Διπλό κλικ για επιλογή του περιεχομένου ως στόχου @@ -1765,6 +1776,8 @@ font-technology Variable Μεταβλητές Message: Μήνυμα: Metadata: Μεταδεδομένα: Method: Μέθοδος: + Pad Συνέχιση + Repeats Επαναλήψεις Millimeters Χιλιοστά Minimize Ελαχιστοποίηση Mode: Λειτουργία: @@ -3095,6 +3108,10 @@ theme Appearance Εμφάνιση Arch Αψίδα @msg select point type or type on a path επιλέξτε κείμενο σημείου ή κείμενο σε διαδρομή @msg type on a path in a thread cannot use Dynamic Text: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να χρησιμοποιήσει δυναμικό κείμενο: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} κείμενο σε ανοιχτή ή σύνθετη διαδρομή δεν μπορεί να κεντραριστεί επάνω ή κάτω: {open} +@msg type on a path with no length or no height cannot be centered: {flat} κείμενο σε διαδρομή χωρίς μήκος ή χωρίς ύψος δεν μπορεί να κεντραριστεί: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να κεντραριστεί επάνω ή κάτω: {frames} +@msg select type on a closed path επιλέξτε κείμενο σε κλειστή διαδρομή Optimize for fast web view Βελτιστοποίηση για γρήγορη προβολή στον ιστό Options for Selection… Επιλογές για την επιλογή… or more spaces with a tab ή περισσότερα διαστήματα με στηλοθέτη @@ -3838,6 +3855,9 @@ digits Malayalam Μαλαγιάλαμ Split Into Grid… Διαίρεση σε πλέγμα… Split Path Διαίρεση διαδρομής spot πλακάτο + Spot Color Objects Αντικείμενα με χρώματα spot + Pattern Objects Αντικείμενα με μοτίβα + Gradient Objects Αντικείμενα με ντεγκραντέ Spot Colors Πλακάτα χρώματα Spot Colors… Πλακάτα χρώματα… Stacked Bar Graph Tool Στοιβαγμένο ραβδόγραμμα (Stacked Bar Graph Tool) @@ -4067,6 +4087,8 @@ digits Malayalam Μαλαγιάλαμ Type on a Path Κείμενο σε διαδρομή Type on a Path Options Επιλογές κειμένου σε διαδρομή Type on a Path Options… Επιλογές κειμένου σε διαδρομή… + Center on Top Κεντράρισμα επάνω + Center on Bottom Κεντράρισμα κάτω Type on a Path Tool Κείμενο σε διαδρομή (Type on a Path Tool) Type Orientation Προσανατολισμός κειμένου Type stays text, and every stroke is a line with its lineweight and dashes. Το κείμενο διατηρείται ως κείμενο και κάθε περίγραμμα γίνεται γραμμή με το πάχος και τις παύλες του. @@ -4356,6 +4378,7 @@ digits Malayalam Μαλαγιάλαμ @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: το `{h}` δεν είναι έγκυρο χρώμα, όπως το "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: επιτρέπονται το πολύ {MAX_TABLE} χρώματα @msg command `{0}` is not available right now: {1} η εντολή `{0}` δεν είναι διαθέσιμη αυτήν τη στιγμή: {1} +@msg command `{id}` is not available right now η εντολή `{id}` δεν είναι διαθέσιμη αυτήν τη στιγμή @msg compound paths can only contain paths οι σύνθετες διαδρομές μπορούν να περιέχουν μόνο διαδρομές @msg compressed (RLE) bitmaps can't be read yet τα συμπιεσμένα (RLE) bitmap δεν μπορούν ακόμη να διαβαστούν @msg damaged κατεστραμμένο @@ -5838,6 +5861,8 @@ effect Points Σημεία @msg the screen color sampler is already open η δειγματοληψία χρώματος από την οθόνη είναι ήδη ανοιχτή @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS αυτό είναι αρχείο PostScript (ένα .ai αποθηκευμένο σε παλαιότερη μορφή ή χωρίς συμβατότητα PDF, ή αρχείο EPS), όχι PDF: οι εντολές Αρχείο › Άνοιγμα και Αρχείο › Τοποθέτηση το διαβάζουν ως EPS @msg this system has no screen color sampler αυτό το σύστημα δεν έχει δειγματοληψία χρώματος από την οθόνη +@msg the screen color sampler did not answer η δειγματοληψία χρώματος από την οθόνη δεν απάντησε +@msg the screen color sampler failed η δειγματοληψία χρώματος από την οθόνη απέτυχε @msg the image can't be read as a texture η εικόνα δεν μπορεί να διαβαστεί ως υφή # User Interface › Accent Color @@ -5991,6 +6016,7 @@ effect Points Σημεία Ruby… Ruby… Ruby Ruby Geometric Guides Γεωμετρικοί οδηγοί + Optical Effects Οπτικά εφέ Geometric Patterns Γεωμετρικά μοτίβα Grit Textures Κοκκώδεις υφές Ease In Ομαλή αρχή @@ -6033,6 +6059,9 @@ axis Y Axis Άξονας Y @msg `dither` applies to gradient fills (paint.setFill) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (paint.setFill) @msg `dither` must be true or false Το `dither` πρέπει να είναι true ή false @msg `method` must be classic or perceptual Το `method` πρέπει να είναι classic ή perceptual +@msg `spread` must be pad, repeat or reflect Το `spread` πρέπει να είναι pad, repeat ή reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} Το `repeats` πρέπει να είναι ακέραιος από 1 έως {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients Το `spread` ισχύει για γραμμικά, ακτινικά, κωνικά και ρομβοειδή ντεγκραντέ # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Να εγκατασταθεί το «{name}», που ζητά μεγάλο υπολογιστικό προϋπολογισμό; Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Κάθε εκτέλεση μπορεί να διαρκέσει έως {minutes} λεπτά και να κάνει {times} φορές τη δουλειά που επιτρέπεται στα άλλα πρόσθετα. Εκτελείται στο παρασκήνιο και μπορείτε να την ακυρώσετε. @@ -6282,6 +6311,11 @@ axis Y Axis Άξονας Y Grass Fan Βεντάλια για γρασίδι Pattern Borders Περιθώρια μοτίβου Scatter Shapes Σχήματα διασποράς + Dry Media Ξηρά μέσα + Chalk Grain Κόκκος κιμωλίας + Graphite Pencil Μολύβι γραφίτη + Wax Crayon Κηρομπογιά + Charcoal Dust Σκόνη κάρβουνου Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων… @msg `{_1}` has no brushes to apply Η `{_1}` δεν έχει πινέλα για εφαρμογή @msg missing `library` (see brush.libraries) λείπει το `library` (δείτε brush.libraries) @@ -6943,3 +6977,8 @@ breakText Glyphs Γλύφοι @msg Some islands could not be joined to the area around the art Ορισμένες νησίδες δεν ήταν δυνατό να συνδεθούν με την περιοχή γύρω από το γραφικό @msg `width` must be from {lo} to {hi} pt Το `width` πρέπει να είναι από {lo} έως {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} Το `count` πρέπει να είναι ακέραιος από 1 έως {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Πλαίσιο και γραμμές αφαίρεσης περισσευμάτων… + Weed Box and Lines Πλαίσιο και γραμμές αφαίρεσης περισσευμάτων + Margin Περιθώριο + Box Πλαίσιο + Swatch Δείγμα χρώματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6d548f6743..28714d37cf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -894,6 +894,17 @@ font-technology Variable Variables The selected character's font is not available. La fuente del carácter seleccionado no está disponible. No alternates for the selected character. No hay alternativas para el carácter seleccionado. Double-click to replace Doble clic para reemplazar + Select one character with the Type tool first. Primero selecciona un carácter con la herramienta Texto. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} es un glifo de {font}, no de la fuente del texto seleccionado ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertenece a {base} (U+{base_code}) {place}; tu selección es {sel} (U+{code}). + at the start of a word al principio de una palabra + in the middle of a word en medio de una palabra + at the end of a word al final de una palabra + standing alone cuando está solo + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} no es una alternativa de {sel} (U+{code}) tal como está en su palabra. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Doble clic para insertar {base} (U+{code}) en su lugar: una forma de presentación no se une, la letra sí. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Se insertó {base} (U+{code}) en lugar de una forma de presentación: la fuente la une a sus vecinas según la posición. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ninguna función OpenType dibuja este glifo aquí, así que sustituye el glifo del carácter y puede que no se una a los caracteres vecinos. Double-click to insert Doble clic para insertar Double-click to rename Doble clic para cambiar el nombre Double-click to target the contents Doble clic para seleccionar el contenido como destino @@ -1994,6 +2005,8 @@ font-technology Variable Variables Metallic Gradients Degradados metálicos Meters Metros Method: Método: + Pad Prolongar + Repeats Repeticiones Millimeters Milímetros Minify Minificar Minimal Mínimo @@ -2360,6 +2373,10 @@ font-technology Variable Variables Arch Bóveda @msg select point type or type on a path seleccione texto de punto o texto en trazado @msg type on a path in a thread cannot use Dynamic Text: {frames} el texto en trazado de un texto enlazado no puede usar texto dinámico: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} el texto en un trazado abierto o compuesto no se puede centrar arriba o abajo: {open} +@msg type on a path with no length or no height cannot be centered: {flat} el texto en un trazado sin longitud o sin altura no se puede centrar: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} el texto en trazado de un texto enlazado no se puede centrar arriba o abajo: {frames} +@msg select type on a closed path seleccione texto en un trazado cerrado Optimize for fast web view Optimizar para vista web rápida Optimized Optimizado Options Opciones @@ -3414,6 +3431,9 @@ digits Malayalam Malayálam Split Path Dividir trazado spot plana Spot Color Tinta plana + Spot Color Objects Objetos con tintas planas + Pattern Objects Objetos con motivos + Gradient Objects Objetos con degradados Spot Colors Tintas planas Spot Colors… Tintas planas… Spread: Extensión: @@ -3723,6 +3743,8 @@ digits Malayalam Malayálam Type on a Path Texto en trazado Type on a Path Options Opciones de texto en trazado Type on a Path Options… Opciones de texto en trazado… + Center on Top Centrar arriba + Center on Bottom Centrar abajo Type on a Path Tool Herramienta Texto en trazado Type Orientation Orientación del texto Type stays text, and every stroke is a line with its lineweight and dashes. El texto sigue siendo texto, y cada trazo es una línea con su grosor y guiones. @@ -4372,6 +4394,8 @@ axis Both Ambos @msg the Smooth slider is not open el regulador de Suavizar no está abierto @msg the screen color sampler is already open el muestreador de color de la pantalla ya está abierto @msg this system has no screen color sampler este sistema no tiene muestreador de color de la pantalla +@msg the screen color sampler did not answer el muestreador de color de la pantalla no respondió +@msg the screen color sampler failed el muestreador de color de la pantalla falló @msg bad colour `{hex}` (#rrggbb) color no válido `{hex}` (#rrggbb) @msg bad page tree árbol de páginas no válido @msg bad shortcut atajo no válido @@ -4401,6 +4425,7 @@ axis Both Ambos @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` no es un color como "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: como máximo {MAX_TABLE} colores @msg command `{0}` is not available right now: {1} el comando `{0}` no está disponible ahora: {1} +@msg command `{id}` is not available right now el comando `{id}` no está disponible ahora @msg compound paths can only contain paths los trazados compuestos solo pueden contener trazados @msg compressed (RLE) bitmaps can't be read yet todavía no se pueden leer mapas de bits comprimidos (RLE) @msg damaged dañado @@ -5982,6 +6007,7 @@ mark-style Japanese Japonesas Ruby… Ruby… Ruby Ruby Geometric Guides Guías geométricas + Optical Effects Efectos ópticos Geometric Patterns Motivos geométricos Grit Textures Texturas granuladas Ease In Entrada suave @@ -6024,6 +6050,9 @@ axis Y Axis Eje Y @msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a rellenos de degradado (paint.setFill) @msg `dither` must be true or false `dither` debe ser true o false @msg `method` must be classic or perceptual `method` debe ser classic o perceptual +@msg `spread` must be pad, repeat or reflect `spread` debe ser pad, repeat o reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` debe ser un número entero de 1 a {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` se aplica a los degradados lineales, radiales, cónicos y de rombo # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? ¿Instalar “{name}”, que pide un presupuesto de cálculo grande? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Cada ejecución puede tardar hasta {minutes} minutos y hacer {times} veces el trabajo que se permite a otros plugins. Se ejecuta en segundo plano y se puede cancelar. @@ -6273,6 +6302,11 @@ axis Y Axis Eje Y Grass Fan Abanico de hierba Pattern Borders Bordes de motivo Scatter Shapes Formas de dispersión + Dry Media Técnicas secas + Chalk Grain Grano de tiza + Graphite Pencil Lápiz de grafito + Wax Crayon Cera de colores + Charcoal Dust Polvo de carboncillo Save Brush Library… Guardar biblioteca de pinceles… @msg `{_1}` has no brushes to apply `{_1}` no tiene pinceles para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) @@ -6934,3 +6968,8 @@ breakText Glyphs Glifos @msg Some islands could not be joined to the area around the art Algunas islas no se pudieron unir al área que rodea la ilustración @msg `width` must be from {lo} to {hi} pt `width` debe estar entre {lo} y {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` debe ser un número entero entre 1 y {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Caja y líneas de pelado… + Weed Box and Lines Caja y líneas de pelado + Margin Margen + Box Caja + Swatch Muestra diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7ae54d2bef..fc809c8928 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -402,6 +402,8 @@ Stair Step Porras Gravity Painovoima Type on a Path Options… Teksti polulla -asetukset… + Center on Top Keskitä ylös + Center on Bottom Keskitä alas Threaded Text Ketjutettu teksti Create Luo Release Selection Vapauta valinta @@ -422,6 +424,10 @@ Arch Holvi @msg select point type or type on a path valitse pisteteksti tai polkuteksti @msg type on a path in a thread cannot use Dynamic Text: {frames} ketjutetun tekstin polkuteksti ei voi käyttää dynaamista tekstiä: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} avoimella polulla tai yhdistelmäpolulla olevaa tekstiä ei voi keskittää ylös tai alas: {open} +@msg type on a path with no length or no height cannot be centered: {flat} polkutekstiä ei voi keskittää polulle, jolla ei ole pituutta tai korkeutta: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} ketjutetun tekstin polkutekstiä ei voi keskittää ylös tai alas: {frames} +@msg select type on a closed path valitse suljetulla polulla oleva teksti Fill with Placeholder Text Täytä paikkamerkkitekstillä Insert Inline Symbol Lisää tekstinsisäinen symboli Show Hidden Characters Näytä piilotetut merkit @@ -1674,6 +1680,17 @@ digits Malayalam Malajalam The selected character's font is not available. Valitun merkin fontti ei ole saatavilla. No alternates for the selected character. Valitulla merkillä ei ole vaihtoehtoja. Double-click to replace Korvaa kaksoisnapsauttamalla + Select one character with the Type tool first. Valitse ensin yksi merkki tekstityökalulla. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} on fontin {font} glyfi, ei valitun tekstin fontin ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} kuuluu merkille {base} (U+{base_code}) {place}; valintasi on {sel} (U+{code}). + at the start of a word sanan alussa + in the middle of a word sanan keskellä + at the end of a word sanan lopussa + standing alone yksinään + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} ei ole merkin {sel} (U+{code}) vaihtoehto sellaisena kuin se on sanassaan. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Lisää kaksoisnapsauttamalla sen sijaan {base} (U+{code}): esitysmuoto ei liity, kirjain liittyy. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Lisättiin {base} (U+{code}) esitysmuodon sijaan: fontti liittää sen naapureihin sijainnin mukaan. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Mikään OpenType-ominaisuus ei piirrä tätä glyfiä tässä, joten se korvaa merkin glyfin eikä välttämättä liity viereisiin merkkeihin. Double-click to insert Lisää kaksoisnapsauttamalla Entire Font Koko fontti glyph {id} merkki {id} @@ -1834,6 +1851,8 @@ digits Malayalam Malajalam Save Image Trace Preset Tallenna kuvan jäljityksen esiasetus Delete Image Trace Preset Poista kuvan jäljityksen esiasetus Method: Menetelmä: + Pad Jatka + Repeats Toistokerrat Mode: Tila: Noise Kohina Create: Luo: @@ -3884,6 +3903,9 @@ theme Appearance Ulkoasu Select All Unused Valitse kaikki käyttämättömät Sort by Kind Lajittele lajin mukaan Sort by Name Lajittele nimen mukaan + Spot Color Objects Objektit, joissa on spottivärejä + Pattern Objects Objektit, joissa on kuvioita + Gradient Objects Objektit, joissa on liukuvärejä Spot Colors Spottivärit Spot Colors (0) Spottivärit (0) Swatch Options Värimallin asetukset @@ -4618,6 +4640,7 @@ imageTrace Outline Ääriviiva @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` ei ole väri, kuten "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: enintään {MAX_TABLE} väriä @msg command `{0}` is not available right now: {1} komento `{0}` ei ole nyt käytettävissä: {1} +@msg command `{id}` is not available right now komento `{id}` ei ole nyt käytettävissä @msg compound paths can only contain paths yhdistelmäpolut voivat sisältää vain polkuja @msg compressed (RLE) bitmaps can't be read yet pakattuja (RLE) bittikarttoja ei voi vielä lukea @msg damaged vaurioitunut @@ -5850,6 +5873,8 @@ mark-style Japanese Japanilainen @msg the screen color sampler is already open värin poiminta näytöltä on jo auki @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS tämä on PostScript-tiedosto (vanhemmassa muodossa tai ilman PDF-yhteensopivuutta tallennettu .ai tai EPS-tiedosto), ei PDF: Tiedosto › Avaa ja Tiedosto › Sijoita lukevat sen EPS-muodossa @msg this system has no screen color sampler tässä järjestelmässä ei ole värin poimintaa näytöltä +@msg the screen color sampler did not answer värin poiminta näytöltä ei vastannut +@msg the screen color sampler failed värin poiminta näytöltä epäonnistui @msg the image can't be read as a texture kuvaa ei voi lukea tekstuurina # User Interface › Accent Color @@ -6070,6 +6095,7 @@ swatch Bright Violet Kirkas violetti Ruby… Ruby… Ruby Ruby Geometric Guides Geometriset apuviivat + Optical Effects Optiset tehosteet Geometric Patterns Geometriset kuviot Grit Textures Rakeiset tekstuurit Ease In Hidas alku @@ -6112,6 +6138,9 @@ axis Y Axis Y-akseli @msg `dither` applies to gradient fills (paint.setFill) `dither` koskee liukuväritäyttöjä (paint.setFill) @msg `dither` must be true or false `dither` on oltava true tai false @msg `method` must be classic or perceptual `method` on oltava classic tai perceptual +@msg `spread` must be pad, repeat or reflect `spread` on oltava pad, repeat tai reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` on oltava kokonaisluku väliltä 1–{MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` koskee lineaarisia, säteittäisiä ja kartiomaisia liukuvärejä sekä vinoneliöliukuvärejä # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Asennetaanko ”{name}”, joka pyytää suurta laskentabudjettia? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Jokainen ajo voi kestää enintään {minutes} minuuttia ja tehdä {times} kertaa niin paljon työtä kuin muut laajennukset saavat. Se toimii taustalla, ja voit peruuttaa sen. @@ -6361,6 +6390,11 @@ axis Y Axis Y-akseli Grass Fan Ruohoviuhka Pattern Borders Kuvioreunukset Scatter Shapes Sirontamuodot + Dry Media Kuivat välineet + Chalk Grain Liitujyvä + Graphite Pencil Grafiittikynä + Wax Crayon Vahaliitu + Charcoal Dust Hiilipöly Save Brush Library… Tallenna sivellinkirjasto… @msg `{_1}` has no brushes to apply Kohteessa `{_1}` ei ole käytettäviä siveltimiä @msg missing `library` (see brush.libraries) `library` puuttuu (katso brush.libraries) @@ -7026,3 +7060,8 @@ breakText Glyphs Merkit @msg Some islands could not be joined to the area around the art Joitakin saarekkeita ei voitu yhdistää grafiikkaa ympäröivään alueeseen @msg `width` must be from {lo} to {hi} pt `width`-arvon on oltava {lo}–{hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` on oltava kokonaisluku 1–{MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Karsintakehys ja -viivat… + Weed Box and Lines Karsintakehys ja -viivat + Margin Marginaali + Box Kehys + Swatch Värimalli diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 35c649f895..0d61a8cf77 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -897,6 +897,17 @@ font-technology Variable Variables The selected character's font is not available. La police du caractère sélectionné n’est pas disponible. No alternates for the selected character. Aucune variante pour le caractère sélectionné. Double-click to replace Double-cliquez pour remplacer + Select one character with the Type tool first. Sélectionnez d’abord un caractère avec l’outil Texte. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} est un glyphe de {font}, et non de la police du texte sélectionné ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartient à {base} (U+{base_code}) {place} ; votre sélection est {sel} (U+{code}). + at the start of a word au début d’un mot + in the middle of a word au milieu d’un mot + at the end of a word à la fin d’un mot + standing alone isolé + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} n’est pas une variante de {sel} (U+{code}) tel qu’il se trouve dans son mot. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Double-cliquez pour insérer {base} (U+{code}) à la place : une forme de présentation ne se lie pas, la lettre si. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) a été inséré à la place d’une forme de présentation : la police le lie à ses voisins selon la position. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Aucune fonction OpenType ne dessine ce glyphe ici : il remplace le glyphe du caractère et peut ne pas se lier aux caractères voisins. Double-click to insert Double-cliquez pour insérer Double-click to rename Double-cliquez pour renommer Double-click to target the contents Double-cliquez pour cibler le contenu @@ -1988,6 +1999,8 @@ font-technology Variable Variables Metallic Gradients Dégradés métalliques Meters Mètres Method: Méthode : + Pad Prolonger + Repeats Répétitions Millimeters Millimètres Minify Minifier Minimal Minimal @@ -2352,6 +2365,10 @@ font-technology Variable Variables Arch Voûte @msg select point type or type on a path sélectionnez du texte de point ou du texte curviligne @msg type on a path in a thread cannot use Dynamic Text: {frames} le texte curviligne d’un texte lié ne peut pas utiliser le texte dynamique : {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} un texte curviligne sur un tracé ouvert ou un tracé transparent ne peut pas être centré en haut ou en bas : {open} +@msg type on a path with no length or no height cannot be centered: {flat} un texte curviligne sur un tracé sans longueur ou sans hauteur ne peut pas être centré : {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} le texte curviligne d’un texte lié ne peut pas être centré en haut ou en bas : {frames} +@msg select type on a closed path sélectionnez un texte curviligne sur un tracé fermé Optimize for fast web view Optimiser pour un affichage web rapide Optimized Optimisé Options Options @@ -3406,6 +3423,9 @@ digits Malayalam Malayalam Split Path Scinder le tracé spot ton direct Spot Color Ton direct + Spot Color Objects Objets avec tons directs + Pattern Objects Objets avec motifs + Gradient Objects Objets avec dégradés Spot Colors Tons directs Spot Colors… Tons directs… Spread: Étendue : @@ -3714,6 +3734,8 @@ digits Malayalam Malayalam Type on a Path Texte curviligne Type on a Path Options Options de texte curviligne Type on a Path Options… Options de texte curviligne… + Center on Top Centrer en haut + Center on Bottom Centrer en bas Type on a Path Tool Outil Texte curviligne Type Orientation Orientation du texte Type stays text, and every stroke is a line with its lineweight and dashes. Le texte reste du texte, et chaque contour devient une ligne avec son épaisseur et ses tirets. @@ -4361,6 +4383,8 @@ axis Both Les deux @msg the Smooth slider is not open le curseur Lisser n’est pas ouvert @msg the screen color sampler is already open l’outil de prélèvement de couleur à l’écran est déjà ouvert @msg this system has no screen color sampler ce système n’a pas d’outil de prélèvement de couleur à l’écran +@msg the screen color sampler did not answer l’outil de prélèvement de couleur à l’écran n’a pas répondu +@msg the screen color sampler failed l’outil de prélèvement de couleur à l’écran a échoué @msg bad colour `{hex}` (#rrggbb) couleur non valide `{hex}` (#rrggbb) @msg bad page tree arborescence des pages non valide @msg bad shortcut raccourci non valide @@ -4390,6 +4414,7 @@ axis Both Les deux @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what} : `{h}` n’est pas une couleur comme "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what} : {MAX_TABLE} couleurs au maximum @msg command `{0}` is not available right now: {1} la commande `{0}` n’est pas disponible pour le moment : {1} +@msg command `{id}` is not available right now la commande `{id}` n’est pas disponible pour le moment @msg compound paths can only contain paths les tracés transparents ne peuvent contenir que des tracés @msg compressed (RLE) bitmaps can't be read yet les images bitmap compressées (RLE) ne peuvent pas encore être lues @msg damaged endommagé @@ -6035,6 +6060,7 @@ theme Appearance Apparence Ruby… Ruby… Ruby Ruby Geometric Guides Repères géométriques + Optical Effects Effets optiques Geometric Patterns Motifs géométriques Grit Textures Textures granuleuses Ease In Accélération progressive @@ -6077,6 +6103,9 @@ axis Y Axis Axe Y @msg `dither` applies to gradient fills (paint.setFill) `dither` s’applique aux fonds en dégradé (paint.setFill) @msg `dither` must be true or false `dither` doit valoir true ou false @msg `method` must be classic or perceptual `method` doit être classic ou perceptual +@msg `spread` must be pad, repeat or reflect `spread` doit être pad, repeat ou reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` doit être un nombre entier de 1 à {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` s’applique aux dégradés linéaires, radiaux, coniques et en losange # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Installer « {name} », qui demande un budget de calcul important ? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Chaque exécution peut durer jusqu’à {minutes} minutes et faire {times} fois le travail permis aux autres modules externes. Elle s’exécute en arrière-plan, et vous pouvez l’annuler. @@ -6326,6 +6355,11 @@ axis Y Axis Axe Y Grass Fan Éventail à herbe Pattern Borders Bordures de motif Scatter Shapes Formes de dispersion + Dry Media Techniques sèches + Chalk Grain Grain de craie + Graphite Pencil Crayon graphite + Wax Crayon Craie grasse + Charcoal Dust Poussière de fusain Save Brush Library… Enregistrer la bibliothèque de formes… @msg `{_1}` has no brushes to apply `{_1}` n'a aucune forme à appliquer @msg missing `library` (see brush.libraries) `library` manquant (voir brush.libraries) @@ -6986,3 +7020,8 @@ breakText Glyphs Glyphes @msg Some islands could not be joined to the area around the art Certains îlots n’ont pas pu être reliés à la zone autour de l’illustration @msg `width` must be from {lo} to {hi} pt `width` doit être compris entre {lo} et {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` doit être un nombre entier compris entre 1 et {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Cadre et lignes d’échenillage… + Weed Box and Lines Cadre et lignes d’échenillage + Margin Marge + Box Cadre + Swatch Nuance diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 335b0c99f0..902a6b5978 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -434,6 +434,11 @@ antiAlias Sharp शार्प Plasma Arc प्लाज़्मा चाप Charge Wave चार्ज तरंग Spark Trail चिंगारी पथ + Dry Media सूखे माध्यम + Chalk Grain चॉक के दाने + Graphite Pencil ग्रेफाइट पेंसिल + Wax Crayon मोम क्रेयॉन + Charcoal Dust चारकोल धूल Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है। Artboard आर्टबोर्ड Artboard {n} of {total} {total} में से आर्टबोर्ड {n} @@ -1423,6 +1428,17 @@ antiAlias Sharp शार्प Double-click to insert सम्मिलित करने के लिए डबल क्लिक करें Double-click to rename नाम बदलने के लिए डबल क्लिक करें Double-click to replace प्रतिस्थापित करने के लिए डबल क्लिक करें + Select one character with the Type tool first. पहले टाइप टूल से एक कैरेक्टर सिलेक्ट करें। + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} {font} का ग्लिफ़ है, उस फ़ॉन्ट का नहीं जिसमें सिलेक्ट किया टेक्स्ट सेट है ({text_font})। + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} {base} (U+{base_code}) का है, {place}; आपका सिलेक्शन {sel} (U+{code}) है। + at the start of a word शब्द की शुरुआत में + in the middle of a word शब्द के बीच में + at the end of a word शब्द के अंत में + standing alone अकेले खड़े होने पर + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}, {sel} (U+{code}) का विकल्प नहीं है, जैसा वह अपने शब्द में खड़ा है। + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. इसके बजाय {base} (U+{code}) सम्मिलित करने के लिए डबल क्लिक करें: प्रेज़ेंटेशन फ़ॉर्म नहीं जुड़ता, अक्षर जुड़ता है। + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. प्रेज़ेंटेशन फ़ॉर्म की जगह {base} (U+{code}) सम्मिलित किया गया: फ़ॉन्ट उसे स्थिति के अनुसार पड़ोसियों से जोड़ता है। + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. यहाँ कोई OpenType फ़ीचर इस ग्लिफ़ को नहीं बनाता, इसलिए यह कैरेक्टर के ग्लिफ़ की जगह लेता है और पड़ोसी कैरेक्टरों से जुड़ नहीं भी सकता। Double-click to target the contents सामग्री को लक्षित करने के लिए डबल क्लिक करें Down (right reading) नीचे (दाएं पढ़ने) Download डाउनलोड @@ -2762,6 +2778,8 @@ antiAlias Sharp शार्प Meters मीटर Method विधि Method: विधि: + Pad विस्तार करें + Repeats दोहराव Mezzotint मेज़ोटिंट Mezzotint… मेज़ोटिंट… Millimeters मिलीमीटर @@ -3183,12 +3201,17 @@ antiAlias Sharp शार्प Opens network connections नेटवर्क कनेक्शन ओपन करें OpenType Features ओपन टाइप सुविधाएँ Optical ऑप्टिकल + Optical Effects ऑप्टिकल इफ़ेक्ट्स Optical Margin Alignment ऑप्टिकल मार्जिन संरेखण Dynamic Text डायनैमिक टेक्स्ट Dynamic Text Presets डायनैमिक टेक्स्ट प्रीसेट Arch मेहराब @msg select point type or type on a path पॉइंट टाइप या पाथ पर टाइप चुनें @msg type on a path in a thread cannot use Dynamic Text: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप डायनैमिक टेक्स्ट का उपयोग नहीं कर सकता: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} खुले पाथ या कंपाउंड पाथ पर टाइप को ऊपर या नीचे केंद्रित नहीं किया जा सकता: {open} +@msg type on a path with no length or no height cannot be centered: {flat} बिना लंबाई या बिना ऊँचाई वाले पाथ पर टाइप केंद्रित नहीं किया जा सकता: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप को ऊपर या नीचे केंद्रित नहीं किया जा सकता: {frames} +@msg select type on a closed path बंद पाथ पर टाइप चुनें Optimize for fast web view तेजी से वेब दृश्य के लिए ऑप्टिमाइज़ Optimized ऑप्टिमाइज़्ड Options ऑप्शन्स @@ -4666,6 +4689,9 @@ antiAlias Sharp शार्प Sponge… स्पंज… spot स्पॉट Spot Color स्पॉट रंग + Spot Color Objects स्पॉट कलर्स के साथ ऑब्जेक्ट्स + Pattern Objects पैटर्न्स के साथ ऑब्जेक्ट्स + Gradient Objects ग्रेडिएंट्स के साथ ऑब्जेक्ट्स Spot Colors स्पॉट रंग Spot Colors (0) स्पॉट कलर्स (0) Spot Colors… स्पॉट कलर्स… @@ -5106,6 +5132,8 @@ antiAlias Sharp शार्प Type on a Path पाथ पर टाइप Type on a Path Options पाथ पर टाइप ऑप्शन्स Type on a Path Options… पाथ पर टाइप ऑप्शन्स… + Center on Top ऊपर केंद्रित करें + Center on Bottom नीचे केंद्रित करें Type on a Path Tool पाथ पर टाइप टूल Type Optimized टाइप ऑप्टिमाइज़ Type Orientation प्रकार अभिविन्यास @@ -5873,6 +5901,9 @@ antiAlias Sharp शार्प @msg `library` is a library id or name (see {list}) `library` एक लाइब्रेरी आईडी या नाम है ( {list} देखें) @msg `line` must be true or false `line` को true या false होना चाहिए @msg `method` must be classic or perceptual `method` क्लासिक या अवधारणात्मक होना चाहिए +@msg `spread` must be pad, repeat or reflect `spread` pad, repeat या reflect होना चाहिए +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 1 से {MAX_REPEATS} तक की पूर्ण संख्या होनी चाहिए +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` रैखिक, रेडियल, शंक्वाकार और डायमंड ग्रेडिएंट्स पर लागू होता है @msg `min` is greater than `max` `min` अधिकतम `max` से अधिक है @msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` और `maxAnchors` पूरे नंबर होना चाहिए @msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°) `maxAngle` ({max_angle}°) से अधिक नहीं हो सकता है। @@ -6294,6 +6325,7 @@ antiAlias Sharp शार्प @msg columns {first} and {column} both name the variable `{name}` कॉलम {first} और {column} दोनों नाम चर `{name}` @msg Combine Slices: the slices have no bounds स्लाइस को मिलाएं: स्लाइस में कोई सीमा नहीं है @msg command `{0}` is not available right now: {1} आदेश `{0}` अभी उपलब्ध नहीं है: {1} +@msg command `{id}` is not available right now आदेश `{id}` अभी उपलब्ध नहीं है @msg command must be a shape.* command command: आदेश एक आकार होना चाहिए।* आदेश @msg commands[{i}]: missing `command` आदेश [{i}]: लापता `command` @msg composer must be "singleLine" or "everyLine" composer: संगीतकार "singleLine" या "everyLine" होना चाहिए। @@ -7911,6 +7943,8 @@ antiAlias Sharp शार्प @msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it इस पोस्टस्क्रिप्ट फ़ाइल को पढ़ा नहीं जा सकता ({why}): इसे उस ऐप में PDF या SVG के रूप में सेव करें जिसने इसे बनाया @msg This shortcut file is in a form VectorCraft can't read. यह शॉर्टकट फ़ाइल एक फॉर्म VectorCraft में है जिसे पढ़ा नहीं जा सकता है। @msg this system has no screen color sampler इस प्रणाली में कोई स्क्रीन रंग नमूना नहीं है +@msg the screen color sampler did not answer स्क्रीन रंग नमूने ने उत्तर नहीं दिया +@msg the screen color sampler failed स्क्रीन रंग नमूना विफल रहा @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset यह निशान {max} एंकर पॉइंट से अधिक होगा, डॉक्यूमेंट की तुलना में अधिक आसानी से दिखा सकते हैं: शोर, निचले पाथ या रंग उठाना, या कम-fidelity प्रीसेट चुनना @msg thumbnail: no page {n} (the file has 1) थंबनेल: कोई पेज {n} (फ़ाइल में 1 है) @msg thumbnail: no page {v} (the {what} has {count}) थंबनेल: कोई पेज {v} ({what} में {count} है) @@ -8110,7 +8144,7 @@ antiAlias Sharp शार्प @msg width must be positive width: चौड़ाई सकारात्मक होना चाहिए @msg width out of range सीमा से बाहर चौड़ाई @msg width/height must be > 0 चौड़ाई / ऊंचाई होना चाहिए > 0 -@msg width/height must be positive and within the canvas चौड़ाई / ऊंचाई सकारात्मक होना चाहिए और कैनवास के भीतर होना चाहिए +@msg width and height must be more than 0 and at most {limit} चौड़ाई और ऊंचाई 0 से अधिक और अधिकतम {limit} होनी चाहिए @msg workspace name can't be empty कार्यक्षेत्र का नाम खाली नहीं हो सकता है @msg x and y must be finite x और y को परिमित होना चाहिए @msg x and y must be finite numbers x और y होना चाहिए परिमित संख्या @@ -8424,3 +8458,8 @@ theme Appearance अपीयरेंस @msg Some islands could not be joined to the area around the art कुछ आइलैंड आर्टवर्क के आसपास के क्षेत्र से नहीं जोड़े जा सके @msg `width` must be from {lo} to {hi} pt `width` {lo} से {hi} pt तक होना चाहिए @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 से {MAX_BRIDGES_PER_ISLAND} तक पूरी संख्या होना चाहिए + Weed Box and Lines… वीड बॉक्स और लाइनें… + Weed Box and Lines वीड बॉक्स और लाइनें + Margin मार्जिन + Box बॉक्स + Swatch स्वॉच diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c3ed22bce8..1028d6d3fc 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1158,6 +1158,17 @@ font-technology Variable Változó The selected character's font is not available. A kijelölt karakter betűtípusa nem érhető el. No alternates for the selected character. A kijelölt karakternek nincsenek változatai. Double-click to replace Csere dupla kattintással + Select one character with the Type tool first. Előbb jelölj ki egy karaktert a Szöveg eszközzel. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). A(z) {glyph} a(z) {font} glifája, nem a kijelölt szöveg betűtípusáé ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). A(z) {glyph} ehhez tartozik: {base} (U+{base_code}) {place}; a te kijelölésed: {sel} (U+{code}). + at the start of a word szó elején + in the middle of a word szó közepén + at the end of a word szó végén + standing alone önállóan állva + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. A(z) {glyph} nem változata a(z) {sel} (U+{code}) karakternek úgy, ahogy a szavában áll. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dupla kattintással ezt szúrod be helyette: {base} (U+{code}); a megjelenítési forma nem kapcsolódik, a betű igen. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. A megjelenítési forma helyett ez lett beszúrva: {base} (U+{code}); a betűtípus pozíció szerint kapcsolja a szomszédokhoz. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Itt egyetlen OpenType-funkció sem rajzolja meg ezt a glifát, ezért a karakter glifáját váltja fel, és előfordulhat, hogy nem kapcsolódik a szomszédos karakterekhez. Double-click to insert Beszúrás dupla kattintással Double-click to rename Dupla kattintással átnevezhető Double-click to target the contents Dupla kattintással a tartalom lesz a cél @@ -2510,6 +2521,8 @@ file-filter Templates Sablonok Meters Méter Method Módszer Method: Módszer: + Pad Folytatás + Repeats Ismétlések Mezzotint Mezzotinto Mezzotint… Mezzotinto… Millimeters Milliméter @@ -2854,6 +2867,10 @@ file-filter Templates Sablonok Arch Boltív @msg select point type or type on a path jelöljön ki pontszöveget vagy útvonalszöveget @msg type on a path in a thread cannot use Dynamic Text: {frames} a láncolt szövegben lévő útvonalszöveg nem használhat dinamikus szöveget: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} nyitott vagy összetett útvonalon lévő útvonalszöveg nem igazítható középre felül vagy alul: {open} +@msg type on a path with no length or no height cannot be centered: {flat} a hossz vagy magasság nélküli útvonalon lévő útvonalszöveg nem igazítható középre: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} a láncolt szövegben lévő útvonalszöveg nem igazítható középre felül vagy alul: {frames} +@msg select type on a closed path jelöljön ki zárt útvonalon lévő útvonalszöveget Optimize for fast web view Optimalizálás gyors webes megtekintéshez Optimized Optimalizált Options Beállítások @@ -4066,6 +4083,9 @@ mark-style Japanese Japán Split Path Útvonal kettévágása spot direkt Spot Color Direktszín + Spot Color Objects Direkt színeket használó objektumok + Pattern Objects Mintázatokat használó objektumok + Gradient Objects Színátmeneteket használó objektumok Spot Colors Direktszínek Spot Colors (0) Direktszínek (0) Spot Colors… Direktszínek… @@ -4440,6 +4460,8 @@ mark-style Japanese Japán Type on a Path Útvonalszöveg Type on a Path Options Útvonalszöveg beállításai Type on a Path Options… Útvonalszöveg beállításai… + Center on Top Középre igazítás felül + Center on Bottom Középre igazítás alul Type on a Path Tool Útvonalszöveg Type Optimized Szövegre optimalizált Type Orientation Szöveg iránya @@ -4916,6 +4938,8 @@ imageTrace Outline Körvonal @msg the Smooth slider is not open a Simítás csúszka nincs megnyitva @msg the screen color sampler is already open a képernyős színmintavevő már nyitva van @msg this system has no screen color sampler ezen a rendszeren nincs képernyős színmintavevő +@msg the screen color sampler did not answer a képernyős színmintavevő nem válaszolt +@msg the screen color sampler failed a képernyős színmintavevő hibába ütközött @msg bad colour `{hex}` (#rrggbb) hibás szín: `{hex}` (#rrggbb) @msg bad page tree hibás oldalfa @msg bad shortcut hibás billentyűparancs @@ -4952,6 +4976,7 @@ imageTrace Outline Körvonal @msg column must be true or a number from 1 a column értéke true vagy 1-től kezdődő szám lehet @msg Combine Slices: the slices have no bounds Szeletek egyesítése: a szeleteknek nincsenek határai @msg command `{0}` is not available right now: {1} a parancs jelenleg nem érhető el: `{0}`: {1} +@msg command `{id}` is not available right now a parancs jelenleg nem érhető el: `{id}` @msg compound paths can only contain paths összetett útvonal csak útvonalakat tartalmazhat @msg compressed (RLE) bitmaps can't be read yet a tömörített (RLE) bitképek még nem olvashatók @msg Convert to Artboards: selection has no area Átalakítás rajztáblákká: a kijelölésnek nincs területe @@ -6023,6 +6048,7 @@ imageTrace Outline Körvonal Ruby… Ruby… Ruby Ruby Geometric Guides Geometriai segédvonalak + Optical Effects Optikai effektusok Geometric Patterns Geometriai mintázatok Grit Textures Szemcsés textúrák Ease In Lassú kezdés @@ -6065,6 +6091,9 @@ axis Y Axis Y tengely @msg `dither` applies to gradient fills (paint.setFill) A `dither` színátmenetes kitöltésekre vonatkozik (paint.setFill) @msg `dither` must be true or false `dither`: true vagy false kell @msg `method` must be classic or perceptual a `method` értéke classic vagy perceptual lehet +@msg `spread` must be pad, repeat or reflect a `spread` értéke pad, repeat vagy reflect lehet +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} a `repeats` értéke 1 és {MAX_REPEATS} közötti egész szám lehet +@msg `spread` applies to linear, radial, conic and diamond gradients a `spread` a lineáris, sugaras, kúpos és rombusz színátmenetekre vonatkozik # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Telepíted a(z) „{name}” bővítményt, amely nagy számítási keretet kér? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Egy futás akár {minutes} percig tarthat, és {times}-szor annyi munkát végezhet, mint amennyit más bővítmények. A háttérben fut, és megszakíthatod. @@ -6314,6 +6343,11 @@ axis Y Axis Y tengely Grass Fan Fűlegyező Pattern Borders Mintázatszegélyek Scatter Shapes Szórt alakzatok + Dry Media Száraz technikák + Chalk Grain Krétaszemcse + Graphite Pencil Grafitceruza + Wax Crayon Zsírkréta + Charcoal Dust Szénpor Save Brush Library… Ecsetkönyvtár mentése… @msg `{_1}` has no brushes to apply A(z) `{_1}` nem tartalmaz alkalmazható ecsetet @msg missing `library` (see brush.libraries) hiányzik a `library` (lásd: brush.libraries) @@ -6975,3 +7009,8 @@ breakText Glyphs Glifák @msg Some islands could not be joined to the area around the art Egyes szigeteket nem sikerült összekötni a grafikát körülvevő területtel @msg `width` must be from {lo} to {hi} pt A `width` értéke {lo} és {hi} pt között lehet @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`: 1 és {MAX_BRIDGES_PER_ISLAND} közötti egész szám kell + Weed Box and Lines… Gyomláló keret és vonalak… + Weed Box and Lines Gyomláló keret és vonalak + Margin Margó + Box Keret + Swatch Színminta diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 87c3f55cd5..c6dad138e3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -366,6 +366,8 @@ Stair Step Undakan tangga Gravity Gravitasi Type on a Path Options… Opsi teks pada jalur… + Center on Top Pusatkan di atas + Center on Bottom Pusatkan di bawah Threaded Text Teks beruntun Create Buat Release Selection Lepaskan seleksi @@ -1611,6 +1613,17 @@ digits Malayalam Malayalam The selected character's font is not available. Font karakter yang dipilih tidak tersedia. No alternates for the selected character. Tidak ada alternatif untuk karakter yang dipilih. Double-click to replace Klik ganda untuk mengganti + Select one character with the Type tool first. Pilih satu karakter dengan alat teks terlebih dahulu. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} adalah glif {font}, bukan dari font yang dipakai teks terpilih ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} milik {base} (U+{base_code}) {place}; pilihan Anda adalah {sel} (U+{code}). + at the start of a word di awal kata + in the middle of a word di tengah kata + at the end of a word di akhir kata + standing alone saat berdiri sendiri + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} bukan alternatif {sel} (U+{code}) sebagaimana posisinya di dalam katanya. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Klik ganda untuk menyisipkan {base} (U+{code}) sebagai gantinya: bentuk presentasi tidak menyambung, hurufnya menyambung. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) disisipkan sebagai ganti bentuk presentasi: font menyambungkannya ke huruf di sebelahnya menurut posisi. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Tidak ada fitur OpenType yang menggambar glif ini di sini, jadi glif ini menggantikan glif karakter dan mungkin tidak menyambung dengan karakter di sebelahnya. Double-click to insert Klik ganda untuk menyisipkan Entire Font Seluruh font glyph {id} glif {id} @@ -1692,6 +1705,8 @@ digits Malayalam Malayalam Save Image Trace Preset Simpan preset telusur gambar Delete Image Trace Preset Hapus preset telusur gambar Method: Metode: + Pad Perpanjang + Repeats Jumlah ulangan Mode: Mode: Noise Derau Create: Buat: @@ -3683,6 +3698,9 @@ theme Appearance Tampilan Select All Unused Pilih semua yang tidak digunakan Sort by Kind Urutkan berdasarkan jenis Sort by Name Urutkan berdasarkan nama + Spot Color Objects Objek dengan Warna Spot + Pattern Objects Objek dengan Pola + Gradient Objects Objek dengan Gradien Spot Colors Warna spot Spot Colors (0) Warna spot (0) Swatch Options Opsi sampel @@ -4357,6 +4375,7 @@ imageTrace Outline Kontur @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` bukan warna seperti "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: maksimal {MAX_TABLE} warna @msg command `{0}` is not available right now: {1} perintah `{0}` tidak tersedia saat ini: {1} +@msg command `{id}` is not available right now perintah `{id}` tidak tersedia saat ini @msg compound paths can only contain paths jalur majemuk hanya dapat berisi jalur @msg compressed (RLE) bitmaps can't be read yet bitmap terkompresi (RLE) belum dapat dibaca @msg damaged rusak @@ -5513,6 +5532,8 @@ mark-style Japanese Jepang @msg the Color Picker is not open Pemilih Warna tidak terbuka @msg the screen color sampler is already open pengambil warna layar sudah terbuka @msg this system has no screen color sampler sistem ini tidak memiliki pengambil warna layar +@msg the screen color sampler did not answer pengambil warna layar tidak merespons +@msg the screen color sampler failed pengambil warna layar gagal swatch [None] [Tidak ada] swatch [Registration] [Registrasi] swatch White Putih @@ -5873,6 +5894,7 @@ swatch Bright Violet Violet cerah Ruby… Ruby… Ruby Ruby Geometric Guides Panduan Geometris + Optical Effects Efek Optis Geometric Patterns Pola Geometris Grit Textures Tekstur Butiran Ease In Awal Lambat @@ -6087,6 +6109,11 @@ axis Y Axis Sumbu Y Grass Fan Kipas rumput Pattern Borders Bingkai pola Scatter Shapes Bentuk sebar + Dry Media Media kering + Chalk Grain Butiran kapur + Graphite Pencil Pensil grafit + Wax Crayon Krayon lilin + Charcoal Dust Debu arang Save Brush Library… Simpan pustaka kuas… @msg no empty artboards to delete tidak ada artboard kosong untuk dihapus Scale Presets Preset skala @@ -6690,4 +6717,9 @@ breakText Glyphs Glif Stencil Bridges… Jembatan Stensil… Stencil Bridges Jembatan Stensil Shortest Crossing Persilangan terpendek + Weed Box and Lines… Kotak dan garis pembersih sisa… + Weed Box and Lines Kotak dan garis pembersih sisa + Margin Margin + Box Kotak + Swatch Sampel warna Direction Arah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a6edc31792..04f24c86ab 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -902,6 +902,17 @@ font-technology Variable Variabili The selected character's font is not available. Il font del carattere selezionato non è disponibile. No alternates for the selected character. Nessuna alternativa per il carattere selezionato. Double-click to replace Fai doppio clic per sostituire + Select one character with the Type tool first. Seleziona prima un carattere con lo strumento Testo. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} è un glifo di {font}, non del font del testo selezionato ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartiene a {base} (U+{base_code}) {place}; la tua selezione è {sel} (U+{code}). + at the start of a word all’inizio di una parola + in the middle of a word in mezzo a una parola + at the end of a word alla fine di una parola + standing alone da solo + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} non è un’alternativa di {sel} (U+{code}) così come si trova nella sua parola. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Fai doppio clic per inserire invece {base} (U+{code}): una forma di presentazione non si unisce, la lettera sì. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Inserito {base} (U+{code}) al posto di una forma di presentazione: il font lo unisce ai vicini in base alla posizione. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nessuna funzione OpenType disegna questo glifo qui: sostituisce il glifo del carattere e potrebbe non unirsi ai caratteri vicini. Double-click to insert Fai doppio clic per inserire Double-click to rename Fai doppio clic per rinominare Double-click to target the contents Fai doppio clic per impostare il contenuto come destinazione @@ -2032,6 +2043,8 @@ file-filter Templates Modelli Metallic Gradients Sfumature metalliche Meters Metri Method: Metodo: + Pad Prolunga + Repeats Ripetizioni Millimeters Millimetri Minify Minifica Minimal Minimale @@ -2398,6 +2411,10 @@ file-filter Templates Modelli Arch Volta @msg select point type or type on a path seleziona testo per punti o testo su tracciato @msg type on a path in a thread cannot use Dynamic Text: {frames} il testo su tracciato in un testo concatenato non può usare il testo dinamico: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} il testo su un tracciato aperto o composto non può essere centrato in alto o in basso: {open} +@msg type on a path with no length or no height cannot be centered: {flat} il testo su un tracciato senza lunghezza o senza altezza non può essere centrato: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} il testo su tracciato in un testo concatenato non può essere centrato in alto o in basso: {frames} +@msg select type on a closed path seleziona del testo su un tracciato chiuso Optimize for fast web view Ottimizza per visualizzazione web veloce Optimized Ottimizzato Options Opzioni @@ -3459,6 +3476,9 @@ digits Malayalam Malayalam Split Path Dividi tracciato spot spot Spot Color Colore spot + Spot Color Objects Oggetti con colori spot + Pattern Objects Oggetti con motivi + Gradient Objects Oggetti con sfumature Spot Colors Colori spot Spot Colors… Colori spot… Spread: Estensione: @@ -3769,6 +3789,8 @@ digits Malayalam Malayalam Type on a Path Testo su tracciato Type on a Path Options Opzioni testo su tracciato Type on a Path Options… Opzioni testo su tracciato… + Center on Top Centra in alto + Center on Bottom Centra in basso Type on a Path Tool Strumento Testo su tracciato Type Orientation Orientamento testo Type stays text, and every stroke is a line with its lineweight and dashes. Il testo resta testo e ogni traccia diventa una linea con il suo spessore e i suoi tratteggi. @@ -4423,6 +4445,8 @@ axis Both Entrambi @msg the Smooth slider is not open il cursore Smussa non è aperto @msg the screen color sampler is already open il campionatore di colore dello schermo è già aperto @msg this system has no screen color sampler questo sistema non ha un campionatore di colore dello schermo +@msg the screen color sampler did not answer il campionatore di colore dello schermo non ha risposto +@msg the screen color sampler failed il campionatore di colore dello schermo ha avuto un errore @msg bad colour `{hex}` (#rrggbb) colore non valido `{hex}` (#rrggbb) @msg bad page tree albero delle pagine non valido @msg bad shortcut scelta rapida non valida @@ -4452,6 +4476,7 @@ axis Both Entrambi @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` non è un colore come "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: al massimo {MAX_TABLE} colori @msg command `{0}` is not available right now: {1} il comando `{0}` non è disponibile in questo momento: {1} +@msg command `{id}` is not available right now il comando `{id}` non è disponibile in questo momento @msg compound paths can only contain paths i tracciati composti possono contenere solo tracciati @msg compressed (RLE) bitmaps can't be read yet le bitmap compresse (RLE) non possono ancora essere lette @msg damaged danneggiato @@ -5663,6 +5688,7 @@ lighting Light Luce Ruby… Ruby… Ruby Ruby Geometric Guides Guide geometriche + Optical Effects Effetti ottici Geometric Patterns Motivi geometrici Grit Textures Texture granulose Ease In Inizio graduale @@ -5707,6 +5733,9 @@ axis Y Axis Asse Y @msg `dither` must be true or false `dither` deve essere true o false Perceptual Percettivo @msg `method` must be classic or perceptual `method` deve essere classic o perceptual +@msg `spread` must be pad, repeat or reflect `spread` deve essere pad, repeat o reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` deve essere un numero intero da 1 a {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` si applica alle sfumature lineari, radiali, coniche e a rombo # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Installare “{name}”, che chiede un budget di calcolo elevato? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Ogni esecuzione può durare fino a {minutes} minuti e svolgere {times} volte il lavoro consentito agli altri plug-in. Viene eseguita in background ed è possibile annullarla. @@ -5956,6 +5985,11 @@ axis Y Axis Asse Y Grass Fan Ventaglio per erba Pattern Borders Bordi a motivo Scatter Shapes Forme a dispersione + Dry Media Tecniche secche + Chalk Grain Grana di gesso + Graphite Pencil Matita di grafite + Wax Crayon Pastello a cera + Charcoal Dust Polvere di carboncino Save Brush Library… Salva libreria pennelli… @msg `{_1}` has no brushes to apply `{_1}` non ha pennelli da applicare @msg missing `library` (see brush.libraries) manca `library` (vedi brush.libraries) @@ -6620,3 +6654,8 @@ breakText Glyphs Glifi @msg Some islands could not be joined to the area around the art Non è stato possibile collegare alcune isole all’area intorno alla grafica @msg `width` must be from {lo} to {hi} pt `width` deve essere compreso tra {lo} e {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve essere un numero intero compreso tra 1 e {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Riquadro e linee di spellicolatura… + Weed Box and Lines Riquadro e linee di spellicolatura + Margin Margine + Box Riquadro + Swatch Campione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 65d7721aa1..0413cc8afc 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -423,6 +423,8 @@ digits Malayalam マラヤーラム数字 Stair Step 階段 Gravity 引力 Type on a Path Options… パス上文字オプション… + Center on Top 上端に中央揃え + Center on Bottom 下端に中央揃え Threaded Text スレッドテキストオプション Create 作成 Release Selection 選択部分をリリース @@ -453,6 +455,10 @@ digits Malayalam マラヤーラム数字 Arch アーチ @msg select point type or type on a path ポイント文字またはパス上文字を選択してください @msg type on a path in a thread cannot use Dynamic Text: {frames} スレッドテキスト内のパス上文字にはダイナミックテキストを使用できません: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} オープンパスや複合パス上のパス上文字は上端または下端に中央揃えできません: {open} +@msg type on a path with no length or no height cannot be centered: {flat} 長さや高さのないパスのパス上文字は中央揃えできません: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} スレッドテキスト内のパス上文字は上端または下端に中央揃えできません: {frames} +@msg select type on a closed path クローズパス上のパス上文字を選択してください Fill with Placeholder Text サンプルテキストの割り付け Insert Inline Symbol インラインシンボルを挿入 Show Hidden Characters 制御文字を表示 @@ -1534,6 +1540,17 @@ antiAlias Sharp シャープ The selected character's font is not available. 選択した文字のフォントを使用できません。 No alternates for the selected character. 選択した文字には異体字がありません。 Double-click to replace ダブルクリックで置換 + Select one character with the Type tool first. 先に文字ツールで文字を 1 つ選択してください。 + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} は {font} のグリフで、選択したテキストのフォント ({text_font}) のものではありません。 + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} は {base} (U+{base_code}) の{place}のグリフです。選択中の文字は {sel} (U+{code}) です。 + at the start of a word 語頭 + in the middle of a word 語中 + at the end of a word 語末 + standing alone 単独のとき + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} は、{sel} (U+{code}) が単語の中で置かれている位置での異体字ではありません。 + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ダブルクリックで代わりに {base} (U+{code}) を挿入: 表示形は前後とつながりませんが、文字はつながります。 + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 表示形の代わりに {base} (U+{code}) を挿入しました。フォントが位置に応じて前後の文字とつなげます。 + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. このグリフを表示する OpenType フィーチャーがないため、文字のグリフが置き換わり、前後の文字とつながらないことがあります。 Double-click to insert ダブルクリックで挿入 Double-click to rename ダブルクリックで名前を変更 Double-click to target the contents ダブルクリックで内容をターゲット @@ -2268,6 +2285,8 @@ antiAlias Sharp シャープ Metadata: メタデータ: Meters メートル Method: 方法: + Pad パッド + Repeats リピート回数 Millimeters ミリメートル Minify 最小化 Minimize 最小化 @@ -3234,6 +3253,9 @@ mark-style Japanese 日本式 Add Guides ガイドを追加 Split Path パスを分割 Spot Color 特色 + Spot Color Objects 特色を使用したオブジェクト + Pattern Objects パターンを使用したオブジェクト + Gradient Objects グラデーションを使用したオブジェクト Spot Colors 特色 Spot Colors… 特色… Spread: 拡散: @@ -4586,6 +4608,7 @@ swatch Bright Violet ビビッドバイオレット @msg nothing to distort 変形するものがありません @msg unknown option `{path}.{k}` `{path}.{k}` は不明なオプションです @msg command `{0}` is not available right now: {1} コマンド `{0}` は現在使用できません: {1} +@msg command `{id}` is not available right now コマンド `{id}` は現在使用できません @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` で内部エラーが発生しました: {msg} (ドキュメントは変更されていません。このバグを報告してください) @msg invalid parameters for `{cmd}`: {msg} `{cmd}` のパラメーターが正しくありません: {msg} @msg no active document アクティブなドキュメントがありません @@ -4778,6 +4801,8 @@ swatch Bright Violet ビビッドバイオレット @msg the Smooth slider is not open スムーズスライダーが開いていません @msg the screen color sampler is already open 画面のカラーサンプラーはすでに開いています @msg this system has no screen color sampler このシステムには画面のカラーサンプラーがありません +@msg the screen color sampler did not answer 画面のカラーサンプラーが応答しませんでした +@msg the screen color sampler failed 画面のカラーサンプラーでエラーが発生しました @msg Corners edits the corners of a path 「コーナー」で編集できるのはパスの角だけです @msg give corners of the path: anchors where its sides meet at an angle パスの角 (辺が角度をなして交わるアンカーポイント) を指定してください @msg select one path, or give its `id` パスを 1 つ選択するか、その `id` を指定してください @@ -5713,6 +5738,7 @@ lighting Light ライト Ruby… ルビ… Ruby ルビ Geometric Guides 幾何ガイド + Optical Effects オプティカル効果 Geometric Patterns 幾何パターン Grit Textures 粒状テクスチャ Ease In イーズイン @@ -5772,6 +5798,9 @@ axis Y Axis Y 軸 @msg `dither` must be true or false `dither` には true または false を指定してください Perceptual 知覚的 @msg `method` must be classic or perceptual `method` には classic または perceptual を指定してください +@msg `spread` must be pad, repeat or reflect `spread` には pad、repeat または reflect を指定してください +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` には 1 から {MAX_REPEATS} までの整数を指定してください +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` は線形、円形、円錐およびひし形グラデーションに適用されます # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? 大きな計算予算を求める「{name}」をインストールしますか? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 1 回の実行に最大 {minutes} 分かかり、ほかのプラグインに許される {times} 倍の処理を行うことがあります。バックグラウンドで実行され、キャンセルできます。 @@ -6021,6 +6050,11 @@ axis Y Axis Y 軸 Grass Fan 草描き扇筆 Pattern Borders パターンボーダー Scatter Shapes 散布シェイプ + Dry Media ドライメディア + Chalk Grain チョークの粒子 + Graphite Pencil 黒鉛鉛筆 + Wax Crayon ワックスクレヨン + Charcoal Dust 木炭の粉 Save Brush Library… ブラシライブラリを保存… @msg `{_1}` has no brushes to apply `{_1}` に適用できるブラシがありません @msg missing `library` (see brush.libraries) `library` がありません (brush.libraries を参照) @@ -6685,3 +6719,8 @@ breakText Glyphs 字形 @msg Some islands could not be joined to the area around the art 一部のアイランドをアートワークの周囲の領域につなげられませんでした @msg `width` must be from {lo} to {hi} pt `width` は {lo}~{hi} pt の範囲で指定してください @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` には 1 から {MAX_BRIDGES_PER_ISLAND} の整数を指定してください + Weed Box and Lines… カス取り枠とライン… + Weed Box and Lines カス取り枠とライン + Margin マージン + Box 枠 + Swatch スウォッチ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index eb686a14a0..5901488cc8 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -331,6 +331,8 @@ Stair Step საფეხურები Gravity გრავიტაცია Type on a Path Options… ტრაექტორიაზე ტექსტის პარამეტრები… + Center on Top ზემოთ ცენტრში მოთავსება + Center on Bottom ქვემოთ ცენტრში მოთავსება Threaded Text დაკავშირებული ტექსტი Create შექმნა Release Selection მონიშნულის მოხსნა @@ -351,6 +353,10 @@ Arch თაღი @msg select point type or type on a path აირჩიეთ წერტილოვანი ტექსტი ან ტრაექტორიაზე ტექსტი @msg type on a path in a thread cannot use Dynamic Text: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ გამოიყენებს დინამიკურ ტექსტს: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} ღია ან შედგენილ ტრაექტორიაზე ტექსტი ვერ მოთავსდება ზემოთ ან ქვემოთ ცენტრში: {open} +@msg type on a path with no length or no height cannot be centered: {flat} ტექსტი ვერ მოთავსდება ცენტრში ტრაექტორიაზე, რომელსაც არ აქვს სიგრძე ან სიმაღლე: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ მოთავსდება ზემოთ ან ქვემოთ ცენტრში: {frames} +@msg select type on a closed path მონიშნეთ დახურულ ტრაექტორიაზე ტექსტი Fill with Placeholder Text შემავსებელი ტექსტით შევსება Insert Inline Symbol ჩადგმული სიმბოლოს ჩასმა Show Hidden Characters დამალული სიმბოლოების ჩვენება @@ -1546,6 +1552,17 @@ digits Malayalam მალაიალამური The selected character's font is not available. მონიშნული სიმბოლოს შრიფტი მიუწვდომელია. No alternates for the selected character. მონიშნულ სიმბოლოს ალტერნატივები არ აქვს. Double-click to replace ორჯერ დაწკაპეთ ჩასანაცვლებლად + Select one character with the Type tool first. ჯერ ტექსტის ხელსაწყოთი მონიშნეთ ერთი სიმბოლო. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} არის {font}-ის გლიფი და არა იმ შრიფტისა, რომლითაც მონიშნული ტექსტია დაწყობილი ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} ეკუთვნის სიმბოლოს {base} (U+{base_code}) {place}; თქვენი მონიშნულია {sel} (U+{code}). + at the start of a word სიტყვის დასაწყისში + in the middle of a word სიტყვის შუაში + at the end of a word სიტყვის ბოლოს + standing alone როცა ცალკე დგას + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} არ არის {sel} (U+{code})-ის ალტერნატივა იმ სახით, როგორც ის თავის სიტყვაშია. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ორჯერ დაწკაპეთ ამის ნაცვლად {base} (U+{code}) ჩასასმელად: გამოსახვის ფორმა არ უკავშირდება, ასო უკავშირდება. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. გამოსახვის ფორმის ნაცვლად ჩასმულია {base} (U+{code}): შრიფტი მას მეზობლებს პოზიციის მიხედვით აკავშირებს. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. აქ არც ერთი OpenType ფუნქცია არ ხატავს ამ გლიფს, ამიტომ ის ანაცვლებს სიმბოლოს გლიფს და შეიძლება არ დაუკავშირდეს მეზობელ სიმბოლოებს. Double-click to insert ორჯერ დაწკაპეთ ჩასასმელად Entire Font მთელი შრიფტი glyph {id} გლიფი {id} @@ -1627,6 +1644,8 @@ digits Malayalam მალაიალამური Delete Image Trace Preset გამოსახულების ვექტორიზაციის პრესეტის წაშლა Cancel Image Trace გამოსახულების ვექტორიზაციის გაუქმება Method: მეთოდი: + Pad გაგრძელება + Repeats გამეორებების რაოდენობა Mode: რეჟიმი: Noise ხმაური Create: შექმნა: @@ -3519,6 +3538,9 @@ theme Appearance იერსახე Select All Unused ყველა გამოუყენებლის მონიშვნა Sort by Kind ტიპის მიხედვით დალაგება Sort by Name სახელის მიხედვით დალაგება + Spot Color Objects ობიექტები სპოტ-ფერებით + Pattern Objects ობიექტები პატერნებით + Gradient Objects ობიექტები გრადიენტებით Spot Colors სპოტ-ფერები Swatch Options ფერის ნიმუშის პარამეტრები Ungroup Color Group ფერების ჯგუფის დაშლა @@ -4172,6 +4194,7 @@ font-technology Variable ცვლადი @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` ფერი არ არის, მაგალითად "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: არაუმეტეს {MAX_TABLE} ფერი @msg command `{0}` is not available right now: {1} ბრძანება `{0}` ახლა ხელმისაწვდომი არ არის: {1} +@msg command `{id}` is not available right now ბრძანება `{id}` ახლა ხელმისაწვდომი არ არის @msg compound paths can only contain paths შედგენილი ტრაექტორია მხოლოდ ტრაექტორიებს შეიძლება შეიცავდეს @msg compressed (RLE) bitmaps can't be read yet შეკუმშული (RLE) ბიტმაპების წაკითხვა ჯერ არ ხერხდება @msg damaged დაზიანებულია @@ -5436,6 +5459,8 @@ imageTrace Outline კონტური @msg the Smooth slider is not open გაგლუვების სლაიდერი გახსნილი არ არის @msg the screen color sampler is already open ეკრანიდან ფერის აღება უკვე გახსნილია @msg this system has no screen color sampler ამ სისტემას ეკრანიდან ფერის აღების საშუალება არ აქვს +@msg the screen color sampler did not answer ეკრანიდან ფერის აღების საშუალებამ არ უპასუხა +@msg the screen color sampler failed ეკრანიდან ფერის აღება ვერ მოხერხდა # Dimension tool and Object › Dimensions. A drafting dimension is ზომა (ზომის დასმა), like a dimension on a technical drawing. Dimension ზომა Drawing : real size — 1:10 labels 1 unit on the page as 10. ნახაზი : რეალური ზომა — 1:10 გვერდზე 1 ერთეულს 10-ად აწერს. @@ -5980,6 +6005,7 @@ imageTrace Outline კონტური Ruby… რუბი… Ruby რუბი Geometric Guides გეომეტრიული მიმმართველები + Optical Effects ოპტიკური ეფექტები Geometric Patterns გეომეტრიული პატერნები Grit Textures მარცვლოვანი ტექსტურები Ease In ნელი დასაწყისი @@ -6033,6 +6059,9 @@ axis Y Axis Y ღერძი @msg `dither` applies to gradient fills (paint.setFill) `dither` ვრცელდება გრადიენტულ შევსებებზე (paint.setFill) @msg `dither` must be true or false `dither` უნდა იყოს true ან false @msg `method` must be classic or perceptual `method` უნდა იყოს classic ან perceptual +@msg `spread` must be pad, repeat or reflect `spread` უნდა იყოს pad, repeat ან reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` უნდა იყოს მთელი რიცხვი 1-დან {MAX_REPEATS}-მდე +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` ეხება წრფივ, რადიალურ, კონუსურ და რომბულ გრადიენტებს # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? დავაყენოთ „{name}“, რომელიც დიდ გამოთვლით ბიუჯეტს ითხოვს? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. თითოეულ გაშვებას შეიძლება {minutes} წუთამდე დასჭირდეს და სხვა პლაგინებისთვის დაშვებულზე {times}-ჯერ მეტი სამუშაო შეასრულოს. ის ფონურ რეჟიმში მუშაობს და შეგიძლიათ გააუქმოთ. @@ -6282,6 +6311,11 @@ axis Y Axis Y ღერძი Grass Fan ბალახის მარაო Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები + Dry Media მშრალი მასალები + Chalk Grain ცარცის მარცვალი + Graphite Pencil გრაფიტის ფანქარი + Wax Crayon ცვილის ფანქარი + Charcoal Dust ნახშირის მტვერი Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა… @msg `{_1}` has no brushes to apply `{_1}`-ში გამოსაყენებელი ფუნჯები არ არის @msg missing `library` (see brush.libraries) `library` აკლია (იხ. brush.libraries) @@ -7105,3 +7139,8 @@ font-script Japanese იაპონური @msg Some islands could not be joined to the area around the art ზოგიერთი კუნძული ვერ დაუკავშირდა ნახატის გარშემო არეს @msg `width` must be from {lo} to {hi} pt `width` უნდა იყოს {lo}-დან {hi} pt-მდე @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` უნდა იყოს მთელი რიცხვი 1-დან {MAX_BRIDGES_PER_ISLAND}-მდე + Weed Box and Lines… ნარჩენის მოსაცილებელი ჩარჩო და ხაზები… + Weed Box and Lines ნარჩენის მოსაცილებელი ჩარჩო და ხაზები + Margin ზღვარი + Box ჩარჩო + Swatch ფერის ნიმუში diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 52a10ab8e8..1db3500541 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -416,6 +416,8 @@ digits Malayalam 말라얄람어 Stair Step 계단 Gravity 중력 Type on a Path Options… 패스 위 문자 옵션… + Center on Top 위쪽에 가운데 맞춤 + Center on Bottom 아래쪽에 가운데 맞춤 Threaded Text 연결된 문자 Create 만들기 Release Selection 선택 해제 @@ -446,6 +448,10 @@ digits Malayalam 말라얄람어 Arch 아치 @msg select point type or type on a path 포인트 문자 또는 패스 위 문자를 선택하십시오 @msg type on a path in a thread cannot use Dynamic Text: {frames} 연결된 문자의 패스 위 문자에는 동적 텍스트를 사용할 수 없습니다: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} 열린 패스나 컴파운드 패스 위 문자는 위쪽이나 아래쪽에 가운데 맞춤할 수 없습니다: {open} +@msg type on a path with no length or no height cannot be centered: {flat} 길이나 높이가 없는 패스 위 문자는 가운데 맞춤할 수 없습니다: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 연결된 문자의 패스 위 문자는 위쪽이나 아래쪽에 가운데 맞춤할 수 없습니다: {frames} +@msg select type on a closed path 닫힌 패스 위 문자를 선택하세요 Fill with Placeholder Text 자리 표시 문자로 채우기 Insert Inline Symbol 인라인 심볼 삽입 Show Hidden Characters 숨겨진 문자 표시 @@ -1524,6 +1530,17 @@ antiAlias Sharp 날카롭게 The selected character's font is not available. 선택한 문자의 글꼴을 사용할 수 없습니다. No alternates for the selected character. 선택한 문자의 대체 글리프가 없습니다. Double-click to replace 더블클릭하여 바꾸기 + Select one character with the Type tool first. 먼저 문자 도구로 문자 하나를 선택하세요. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph}은(는) {font}의 글리프이며, 선택한 텍스트의 글꼴({text_font})의 것이 아닙니다. + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph}은(는) {base}(U+{base_code})의 {place} 글리프입니다. 선택한 문자는 {sel}(U+{code})입니다. + at the start of a word 단어 처음 + in the middle of a word 단어 중간 + at the end of a word 단어 끝 + standing alone 단독일 때 + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}은(는) 단어 안에 있는 {sel}(U+{code})의 대체 글리프가 아닙니다. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 더블클릭하여 대신 {base}(U+{code}) 삽입: 표시 형태는 연결되지 않지만 글자는 연결됩니다. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 표시 형태 대신 {base}(U+{code})을(를) 삽입했습니다. 글꼴이 위치에 따라 이웃 문자와 연결합니다. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 이 글리프를 그리는 OpenType 기능이 없으므로 문자의 글리프를 대체하며 이웃 문자와 연결되지 않을 수 있습니다. Double-click to insert 더블클릭하여 삽입 Double-click to rename 더블클릭하여 이름 변경 Double-click to target the contents 더블클릭하여 내용 선택 @@ -2258,6 +2275,8 @@ antiAlias Sharp 날카롭게 Metadata: 메타데이터: Meters 미터 Method: 방법: + Pad 연장 + Repeats 반복 횟수 Millimeters 밀리미터 Minify 압축 Minimize 최소화 @@ -3223,6 +3242,9 @@ mark-style Japanese 일본식 Add Guides 안내선 추가 Split Path 패스 분할 Spot Color 별색 + Spot Color Objects 별색을 사용한 개체 + Pattern Objects 패턴을 사용한 개체 + Gradient Objects 그레이디언트를 사용한 개체 Spot Colors 별색 Spot Colors… 별색… Spread: 확산: @@ -4513,6 +4535,7 @@ theme Appearance 외관 @msg nothing to distort 왜곡할 항목이 없습니다 @msg unknown option `{path}.{k}` 알 수 없는 옵션 `{path}.{k}` @msg command `{0}` is not available right now: {1} 명령 `{0}`은(는) 지금 사용할 수 없습니다: {1} +@msg command `{id}` is not available right now 명령 `{id}`은(는) 지금 사용할 수 없습니다 @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}`에서 내부 오류가 발생했습니다: {msg}(문서는 이전 상태로 유지되었습니다. 이 버그를 신고해 주세요) @msg invalid parameters for `{cmd}`: {msg} `{cmd}`의 매개변수가 올바르지 않습니다: {msg} @msg no active document 활성 문서가 없습니다 @@ -4705,6 +4728,8 @@ theme Appearance 외관 @msg the Smooth slider is not open 매끄럽게 슬라이더가 열려 있지 않습니다 @msg the screen color sampler is already open 화면 색상 추출기가 이미 열려 있습니다 @msg this system has no screen color sampler 이 시스템에는 화면 색상 추출기가 없습니다 +@msg the screen color sampler did not answer 화면 색상 추출기가 응답하지 않았습니다 +@msg the screen color sampler failed 화면 색상 추출기에 오류가 발생했습니다 @msg Corners edits the corners of a path 모퉁이는 패스의 모퉁이를 편집합니다 @msg give corners of the path: anchors where its sides meet at an angle 패스의 모퉁이, 즉 변이 각을 이루며 만나는 앵커를 지정하세요 @msg select one path, or give its `id` 패스를 하나 선택하거나 `id`를 지정하세요 @@ -5624,6 +5649,7 @@ lighting Light 광원 Ruby… 루비… Ruby 루비 Geometric Guides 기하학적 안내선 + Optical Effects 광학 효과 Geometric Patterns 기하학적 패턴 Grit Textures 입자 텍스처 Ease In 서서히 시작 @@ -5668,6 +5694,9 @@ axis Y Axis Y축 @msg `dither` must be true or false `dither`은(는) true 또는 false여야 합니다 Perceptual 지각적 @msg `method` must be classic or perceptual `method`는 classic 또는 perceptual이어야 합니다 +@msg `spread` must be pad, repeat or reflect `spread`는 pad, repeat 또는 reflect여야 합니다 +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats`는 1에서 {MAX_REPEATS} 사이의 정수여야 합니다 +@msg `spread` applies to linear, radial, conic and diamond gradients `spread`는 선형, 방사형, 원뿔형 및 다이아몬드 그레이디언트에 적용됩니다 # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? 큰 계산 예산을 요청하는 “{name}”을(를) 설치하시겠습니까? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 한 번 실행에 최대 {minutes}분이 걸리고 다른 플러그인에 허용되는 작업량의 {times}배를 수행할 수 있습니다. 백그라운드에서 실행되며 취소할 수 있습니다. @@ -5917,6 +5946,11 @@ axis Y Axis Y축 Grass Fan 잔디 부채붓 Pattern Borders 패턴 테두리 Scatter Shapes 흩뿌리기 모양 + Dry Media 건식 재료 + Chalk Grain 분필 입자 + Graphite Pencil 흑연 연필 + Wax Crayon 왁스 크레용 + Charcoal Dust 목탄 가루 Save Brush Library… 브러시 라이브러리 저장… @msg `{_1}` has no brushes to apply `{_1}`에 적용할 브러시가 없습니다 @msg missing `library` (see brush.libraries) `library`가 없습니다(brush.libraries 참조) @@ -6581,3 +6615,8 @@ breakText Glyphs 글리프 @msg Some islands could not be joined to the area around the art 일부 섬을 아트워크 주변 영역에 연결할 수 없습니다 @msg `width` must be from {lo} to {hi} pt `width`는 {lo}에서 {hi}pt 사이여야 합니다 @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`는 1에서 {MAX_BRIDGES_PER_ISLAND} 사이의 정수여야 합니다 + Weed Box and Lines… 위딩 상자 및 선… + Weed Box and Lines 위딩 상자 및 선 + Margin 여백 + Box 상자 + Swatch 견본 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4f9d484155..0400283630 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -380,6 +380,8 @@ Stair Step Trap Gravity Zwaartekracht Type on a Path Options… Opties voor tekst op pad… + Center on Top Boven centreren + Center on Bottom Onder centreren Threaded Text Gekoppelde tekst Create Maken Release Selection Selectie vrijgeven @@ -1625,6 +1627,17 @@ digits Malayalam Malayalam The selected character's font is not available. Het lettertype van het geselecteerde teken is niet beschikbaar. No alternates for the selected character. Geen alternatieven voor het geselecteerde teken. Double-click to replace Dubbelklik om te vervangen + Select one character with the Type tool first. Selecteer eerst één teken met het Type-gereedschap. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} is een glyph van {font}, niet van het lettertype van de geselecteerde tekst ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hoort bij {base} (U+{base_code}) {place}; uw selectie is {sel} (U+{code}). + at the start of a word aan het begin van een woord + in the middle of a word midden in een woord + at the end of a word aan het einde van een woord + standing alone los staand + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} is geen alternatief voor {sel} (U+{code}) zoals het in zijn woord staat. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dubbelklik om in plaats daarvan {base} (U+{code}) in te voegen: een presentatievorm sluit niet aan, de letter wel. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) ingevoegd in plaats van een presentatievorm: het lettertype verbindt het naar positie met de buren. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Geen enkele OpenType-functie tekent deze glyph hier, dus hij vervangt de glyph van het teken en sluit mogelijk niet aan op de buurtekens. Double-click to insert Dubbelklik om in te voegen Entire Font Volledig lettertype glyph {id} glyph {id} @@ -1706,6 +1719,8 @@ digits Malayalam Malayalam Save Image Trace Preset Voorinstelling voor afbeeldingtracering opslaan Delete Image Trace Preset Voorinstelling voor afbeeldingtracering verwijderen Method: Methode: + Pad Doortrekken + Repeats Herhalingen Mode: Modus: Noise Ruis Create: Maken: @@ -3697,6 +3712,9 @@ theme Appearance Vormgeving Select All Unused Alle ongebruikte selecteren Sort by Kind Sorteren op soort Sort by Name Sorteren op naam + Spot Color Objects Objecten met spotkleuren + Pattern Objects Objecten met patronen + Gradient Objects Objecten met verlopen Spot Colors Spotkleuren Spot Colors (0) Spotkleuren (0) Swatch Options Kleurstaalopties @@ -4371,6 +4389,7 @@ imageTrace Outline Contour @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` is geen kleur zoals "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: maximaal {MAX_TABLE} kleuren @msg command `{0}` is not available right now: {1} opdracht `{0}` is nu niet beschikbaar: {1} +@msg command `{id}` is not available right now opdracht `{id}` is nu niet beschikbaar @msg compound paths can only contain paths samengestelde paden kunnen alleen paden bevatten @msg compressed (RLE) bitmaps can't be read yet gecomprimeerde (RLE) bitmaps kunnen nog niet gelezen worden @msg damaged beschadigd @@ -5521,6 +5540,8 @@ mark-style Japanese Japans @msg the Color Picker is not open de kleurenkiezer is niet open @msg the screen color sampler is already open de schermkleurkiezer is al open @msg this system has no screen color sampler dit systeem heeft geen schermkleurkiezer +@msg the screen color sampler did not answer de schermkleurkiezer heeft niet geantwoord +@msg the screen color sampler failed de schermkleurkiezer is mislukt swatch [None] [Geen] swatch [Registration] [Passers] swatch White Wit @@ -5924,6 +5945,7 @@ swatch Bright Violet Felviolet Ruby… Ruby… Ruby Ruby Geometric Guides Geometrische hulplijnen + Optical Effects Optische effecten Geometric Patterns Geometrische patronen Grit Textures Korrelige texturen Ease In Langzaam beginnen @@ -6138,6 +6160,11 @@ axis Y Axis Y-as Grass Fan Graswaaier Pattern Borders Patroonranden Scatter Shapes Strooivormen + Dry Media Droge media + Chalk Grain Krijtkorrel + Graphite Pencil Grafietpotlood + Wax Crayon Waskrijt + Charcoal Dust Houtskoolstof Save Brush Library… Penseelbibliotheek opslaan… @msg no empty artboards to delete geen lege tekengebieden om te verwijderen Scale Presets Schaalvoorinstellingen @@ -6741,4 +6768,9 @@ breakText Glyphs Glyphs Stencil Bridges… Sjabloonbruggen… Stencil Bridges Sjabloonbruggen Shortest Crossing Kortste oversteek + Weed Box and Lines… Wiedkader en wiedlijnen… + Weed Box and Lines Wiedkader en wiedlijnen + Margin Marge + Box Kader + Swatch Staal Direction Richting diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e44328480f..6e595e8673 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -364,6 +364,8 @@ Stair Step Schodki Gravity Grawitacja Type on a Path Options… Opcje tekstu na ścieżce… + Center on Top Wyśrodkuj na górze + Center on Bottom Wyśrodkuj na dole Threaded Text Tekst wątkowy Create Utwórz Release Selection Uwolnij zaznaczenie @@ -384,6 +386,10 @@ Arch Sklepienie @msg select point type or type on a path zaznacz tekst punktowy lub tekst na ścieżce @msg type on a path in a thread cannot use Dynamic Text: {frames} tekst na ścieżce w tekście wątkowym nie może używać tekstu dynamicznego: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} nie można wyśrodkować na górze ani na dole tekstu na ścieżce otwartej lub złożonej: {open} +@msg type on a path with no length or no height cannot be centered: {flat} nie można wyśrodkować tekstu na ścieżce bez długości lub bez wysokości: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} nie można wyśrodkować na górze ani na dole tekstu na ścieżce w tekście wątkowym: {frames} +@msg select type on a closed path zaznacz tekst na zamkniętej ścieżce Fill with Placeholder Text Wypełnij tekstem zastępczym Insert Inline Symbol Wstaw symbol w tekście Show Hidden Characters Pokaż ukryte znaki @@ -1613,6 +1619,17 @@ digits Malayalam Malajalam The selected character's font is not available. Czcionka zaznaczonego znaku jest niedostępna. No alternates for the selected character. Brak alternatyw dla zaznaczonego znaku. Double-click to replace Kliknij dwukrotnie, aby zastąpić + Select one character with the Type tool first. Najpierw zaznacz jeden znak narzędziem Tekst. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} to glif czcionki {font}, a nie czcionki zaznaczonego tekstu ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} należy do znaku {base} (U+{base_code}) {place}; Twoje zaznaczenie to {sel} (U+{code}). + at the start of a word na początku wyrazu + in the middle of a word w środku wyrazu + at the end of a word na końcu wyrazu + standing alone gdy stoi osobno + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} nie jest alternatywą znaku {sel} (U+{code}) w takiej postaci, w jakiej stoi w swoim wyrazie. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Kliknij dwukrotnie, aby wstawić zamiast tego {base} (U+{code}): forma prezentacyjna się nie łączy, litera tak. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Wstawiono {base} (U+{code}) zamiast formy prezentacyjnej: czcionka łączy go z sąsiadami zależnie od pozycji. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Żadna funkcja OpenType nie rysuje tutaj tego glifu, więc zastępuje on glif znaku i może nie łączyć się z sąsiednimi znakami. Double-click to insert Kliknij dwukrotnie, aby wstawić Entire Font Cała czcionka glyph {id} glif {id} @@ -1779,6 +1796,8 @@ digits Malayalam Malajalam Save Image Trace Preset Zapisz ustawienie obrysu obrazu Delete Image Trace Preset Usuń ustawienie obrysu obrazu Method: Metoda: + Pad Przedłuż + Repeats Liczba powtórzeń Mode: Tryb: Noise Szum Create: Utwórz: @@ -3819,6 +3838,9 @@ theme Appearance Wygląd Select All Unused Zaznacz wszystkie nieużywane Sort by Kind Sortuj według rodzaju Sort by Name Sortuj według nazwy + Spot Color Objects Obiekty z kolorami dodatkowymi + Pattern Objects Obiekty ze wzorkami + Gradient Objects Obiekty z gradientami Spot Colors Kolory dodatkowe Swatch Options Opcje próbki Ungroup Color Group Rozgrupuj grupę kolorów @@ -4504,6 +4526,8 @@ imageTrace Outline Kontur @msg the Smooth slider is not open Suwak Wygładź nie jest otwarty @msg the screen color sampler is already open narzędzie do pobierania koloru z ekranu jest już otwarte @msg this system has no screen color sampler ten system nie ma narzędzia do pobierania koloru z ekranu +@msg the screen color sampler did not answer narzędzie do pobierania koloru z ekranu nie odpowiedziało +@msg the screen color sampler failed narzędzie do pobierania koloru z ekranu zgłosiło błąd @msg bad colour `{hex}` (#rrggbb) nieprawidłowy kolor `{hex}` (#rrggbb) @msg bad page tree nieprawidłowe drzewo stron @msg bad shortcut nieprawidłowy skrót @@ -4533,6 +4557,7 @@ imageTrace Outline Kontur @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` nie jest kolorem w rodzaju "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: maksymalnie {MAX_TABLE} kolorów @msg command `{0}` is not available right now: {1} polecenie `{0}` jest teraz niedostępne: {1} +@msg command `{id}` is not available right now polecenie `{id}` jest teraz niedostępne @msg compound paths can only contain paths ścieżki złożone mogą zawierać tylko ścieżki @msg compressed (RLE) bitmaps can't be read yet skompresowanych bitmap (RLE) nie można jeszcze odczytać @msg damaged uszkodzony @@ -5894,6 +5919,7 @@ mark-style Japanese Japońskie Ruby… Ruby… Ruby Ruby Geometric Guides Geometryczne linie pomocnicze + Optical Effects Efekty optyczne Geometric Patterns Wzorki geometryczne Grit Textures Tekstury ziarniste Ease In Łagodny początek @@ -5936,6 +5962,9 @@ axis Y Axis Oś Y @msg `dither` applies to gradient fills (paint.setFill) `dither` dotyczy wypełnień gradientem (paint.setFill) @msg `dither` must be true or false `dither` musi mieć wartość true lub false @msg `method` must be classic or perceptual `method` musi mieć wartość classic lub perceptual +@msg `spread` must be pad, repeat or reflect `spread` musi mieć wartość pad, repeat lub reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` musi być liczbą całkowitą od 1 do {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` dotyczy gradientów liniowych, radialnych, stożkowych i rombowych # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Zainstalować „{name}”, wtyczkę, która prosi o duży budżet obliczeń? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Każde uruchomienie może trwać do {minutes} minut i wykonać {times} razy więcej pracy, niż mogą inne wtyczki. Działa w tle i można je anulować. @@ -6185,6 +6214,11 @@ axis Y Axis Oś Y Grass Fan Wachlarz do trawy Pattern Borders Obramowania wzorków Scatter Shapes Kształty rozproszone + Dry Media Techniki suche + Chalk Grain Ziarno kredy + Graphite Pencil Ołówek grafitowy + Wax Crayon Kredka woskowa + Charcoal Dust Pył węglowy Save Brush Library… Zapisz bibliotekę pędzli… @msg `{_1}` has no brushes to apply `{_1}` nie ma pędzli do zastosowania @msg missing `library` (see brush.libraries) brak `library` (zobacz brush.libraries) @@ -6847,3 +6881,8 @@ breakText Glyphs Glify @msg Some islands could not be joined to the area around the art Niektórych wysp nie udało się połączyć z obszarem wokół grafiki @msg `width` must be from {lo} to {hi} pt `width` musi mieścić się w zakresie od {lo} do {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musi być liczbą całkowitą od 1 do {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Ramka i linie do wybierania… + Weed Box and Lines Ramka i linie do wybierania + Margin Margines + Box Ramka + Swatch Próbka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3ce111f7a4..d20df47512 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -804,6 +804,17 @@ antiAlias Sharp Nítido The selected character's font is not available. A fonte do caractere selecionado não está disponível. No alternates for the selected character. Nenhuma alternativa para o caractere selecionado. Double-click to replace Clique duplo para substituir + Select one character with the Type tool first. Primeiro selecione um caractere com a ferramenta Texto. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} é um glifo de {font}, não da fonte do texto selecionado ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertence a {base} (U+{base_code}) {place}; sua seleção é {sel} (U+{code}). + at the start of a word no início de uma palavra + in the middle of a word no meio de uma palavra + at the end of a word no fim de uma palavra + standing alone quando está sozinho + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} não é uma alternativa de {sel} (U+{code}) como ele está em sua palavra. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Clique duplo para inserir {base} (U+{code}) no lugar: uma forma de apresentação não se une, a letra sim. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) foi inserido no lugar de uma forma de apresentação: a fonte o une aos vizinhos conforme a posição. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nenhum recurso OpenType desenha este glifo aqui, então ele substitui o glifo do caractere e pode não se unir aos caracteres vizinhos. Double-click to insert Clique duplo para inserir Double-click to rename Clique duplo para renomear Double-click to target the contents Clique duplo para direcionar o conteúdo @@ -1701,6 +1712,8 @@ antiAlias Sharp Nítido Metallic Gradients Gradientes Metálicos Meters Metros Method: Método: + Pad Prolongar + Repeats Repetições Millimeters Milímetros Minify Minificar Minimize Minimizar @@ -1956,6 +1969,10 @@ antiAlias Sharp Nítido Arch Abóbada @msg select point type or type on a path selecione texto de ponto ou texto em um caminho @msg type on a path in a thread cannot use Dynamic Text: {frames} o texto em um caminho de um texto encadeado não pode usar Texto Dinâmico: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} o texto em um caminho aberto ou composto não pode ser centralizado em cima ou embaixo: {open} +@msg type on a path with no length or no height cannot be centered: {flat} o texto em um caminho sem comprimento ou sem altura não pode ser centralizado: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} o texto em um caminho de um texto encadeado não pode ser centralizado em cima ou embaixo: {frames} +@msg select type on a closed path selecione texto em um caminho fechado Optimize for fast web view Otimizar para visualização rápida na web Optimized Otimizado Options Opções @@ -2866,6 +2883,9 @@ digits Malayalam Malaiala Split Into Grid… Dividir em Grade… Split Path Dividir Caminho Spot Color Cor Spot + Spot Color Objects Objetos com cores spot + Pattern Objects Objetos com padrões + Gradient Objects Objetos com gradientes Spot Colors Cores Spot Spot Colors… Cores Spot… Spread: Propagação: @@ -3068,6 +3088,8 @@ digits Malayalam Malaiala Type becomes outlines, and strokes a CAD line can't draw become filled shapes. O texto vira contornos, e os traços que uma linha CAD não pode desenhar viram formas preenchidas. Type on a Path Texto em um Caminho Type on a Path Options… Opções de Texto em um Caminho… + Center on Top Centralizar em Cima + Center on Bottom Centralizar em Baixo Type on a Path Tool Ferramenta Texto em um Caminho Type stays text, and every stroke is a line with its lineweight and dashes. O texto permanece texto, e todo traço é uma linha com sua espessura e tracejados. Type: Tipo: @@ -3941,6 +3963,7 @@ axis Both Ambos @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` não é uma cor como "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: no máximo {MAX_TABLE} cores @msg command `{0}` is not available right now: {1} o comando `{0}` não está disponível agora: {1} +@msg command `{id}` is not available right now o comando `{id}` não está disponível agora @msg compound paths can only contain paths traçados compostos só podem conter traçados @msg compressed (RLE) bitmaps can't be read yet bitmaps compactados (RLE) ainda não podem ser lidos @msg damaged danificado @@ -5787,6 +5810,8 @@ mark-style Japanese Japonesas @msg the Smooth slider is not open o controle deslizante Suavizar não está aberto @msg the screen color sampler is already open o coletor de cores da tela já está aberto @msg this system has no screen color sampler este sistema não tem coletor de cores da tela +@msg the screen color sampler did not answer o coletor de cores da tela não respondeu +@msg the screen color sampler failed o coletor de cores da tela falhou Darkness Escuridão Dimension Cota Dimension Tool Options Opções da Ferramenta Cota @@ -5955,6 +5980,7 @@ mark-style Japanese Japonesas Ruby… Ruby… Ruby Ruby Geometric Guides Guias geométricas + Optical Effects Efeitos ópticos Geometric Patterns Padrões geométricos Grit Textures Texturas granuladas Ease In Entrada suave @@ -5997,6 +6023,9 @@ axis Y Axis Eixo Y @msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a preenchimentos em gradiente (paint.setFill) @msg `dither` must be true or false `dither` deve ser true ou false @msg `method` must be classic or perceptual `method` deve ser classic ou perceptual +@msg `spread` must be pad, repeat or reflect `spread` deve ser pad, repeat ou reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` deve ser um número inteiro de 1 a {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` se aplica a gradientes lineares, radiais, cônicos e em losango # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Instalar “{name}”, que pede um orçamento de processamento grande? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Cada execução pode levar até {minutes} minutos e fazer {times} vezes o trabalho permitido a outros plug-ins. Ela é executada em segundo plano, e você pode cancelá-la. @@ -6246,6 +6275,11 @@ axis Y Axis Eixo Y Grass Fan Leque de Grama Pattern Borders Bordas de Padrão Scatter Shapes Formas de Dispersão + Dry Media Técnicas Secas + Chalk Grain Grão de Giz + Graphite Pencil Lápis de Grafite + Wax Crayon Giz de Cera + Charcoal Dust Pó de Carvão Save Brush Library… Salvar Biblioteca de Pincéis… @msg `{_1}` has no brushes to apply `{_1}` não tem pincéis para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) @@ -7064,3 +7098,8 @@ swatch Yellow Green Verde-amarelado @msg Some islands could not be joined to the area around the art Algumas ilhas não puderam ser unidas à área ao redor da arte @msg `width` must be from {lo} to {hi} pt `width` deve estar entre {lo} e {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve ser um número inteiro de 1 a {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Caixa e Linhas de Descasque… + Weed Box and Lines Caixa e Linhas de Descasque + Margin Margem + Box Caixa + Swatch Amostra diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c93807c08a..9d1e2d0035 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -892,6 +892,17 @@ font-technology Variable Вариативные The selected character's font is not available. Шрифт выделенного символа недоступен. No alternates for the selected character. У выделенного символа нет альтернатив. Double-click to replace Дважды щёлкните для замены + Select one character with the Type tool first. Сначала выделите один символ инструментом «Текст». + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} — глиф шрифта {font}, а не шрифта выделенного текста ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} относится к символу {base} (U+{base_code}) {place}; выделен символ {sel} (U+{code}). + at the start of a word в начале слова + in the middle of a word в середине слова + at the end of a word в конце слова + standing alone в отдельном виде + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не является альтернативой символа {sel} (U+{code}) в том виде, как он стоит в своём слове. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Дважды щёлкните, чтобы вставить вместо него {base} (U+{code}): форма представления не соединяется, буква соединяется. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Вместо формы представления вставлено {base} (U+{code}): шрифт соединяет его с соседними по положению. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ни одна функция OpenType не рисует здесь этот глиф, поэтому он заменяет глиф символа и может не соединяться с соседними символами. Double-click to insert Дважды щёлкните для вставки Double-click to rename Дважды щёлкните для переименования Double-click to target the contents Дважды щёлкните, чтобы выбрать содержимое как объект @@ -2021,6 +2032,8 @@ file-filter Templates Шаблоны Metallic Gradients Металлические градиенты Meters Метры Method: Метод: + Pad Продлить + Repeats Число повторов Millimeters Миллиметры Minify Минифицировать Minimal Минимальный @@ -2385,6 +2398,10 @@ file-filter Templates Шаблоны Arch Арка @msg select point type or type on a path выберите точечный текст или текст по контуру @msg type on a path in a thread cannot use Dynamic Text: {frames} текст по контуру в связанном тексте не может использовать динамический текст: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} нельзя центрировать вверху или внизу текст по незамкнутому или составному контуру: {open} +@msg type on a path with no length or no height cannot be centered: {flat} нельзя центрировать текст по контуру без длины или без высоты: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} нельзя центрировать вверху или внизу текст по контуру в связанном тексте: {frames} +@msg select type on a closed path выделите текст по замкнутому контуру Optimize for fast web view Оптимизировать для быстрого просмотра в web Optimized Оптимизировано Options Параметры @@ -3446,6 +3463,9 @@ digits Malayalam Малаялам Split Path Разделить контур spot плашечный Spot Color Плашечный цвет + Spot Color Objects Объекты с плашечными цветами + Pattern Objects Объекты с узорами + Gradient Objects Объекты с градиентами Spot Colors Плашечные цвета Spot Colors… Плашечные цвета… Spread: Разворот: @@ -3755,6 +3775,8 @@ digits Malayalam Малаялам Type on a Path Текст по контуру Type on a Path Options Параметры текста по контуру Type on a Path Options… Параметры текста по контуру… + Center on Top Центрировать вверху + Center on Bottom Центрировать внизу Type on a Path Tool Инструмент «Текст по контуру» Type Orientation Ориентация текста Type stays text, and every stroke is a line with its lineweight and dashes. Текст остаётся текстом, и каждая обводка — линия с её толщиной и штрихами. @@ -4409,6 +4431,8 @@ axis Both Оба @msg the Smooth slider is not open ползунок «Сгладить» не открыт @msg the screen color sampler is already open средство выбора цвета с экрана уже открыто @msg this system has no screen color sampler в этой системе нет средства выбора цвета с экрана +@msg the screen color sampler did not answer средство выбора цвета с экрана не ответило +@msg the screen color sampler failed в средстве выбора цвета с экрана произошла ошибка @msg bad colour `{hex}` (#rrggbb) недопустимый цвет `{hex}` (#rrggbb) @msg bad page tree недопустимое дерево страниц @msg bad shortcut недопустимое сочетание клавиш @@ -4438,6 +4462,7 @@ axis Both Оба @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` не является цветом, например "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: не более {MAX_TABLE} цветов @msg command `{0}` is not available right now: {1} команда `{0}` сейчас недоступна: {1} +@msg command `{id}` is not available right now команда `{id}` сейчас недоступна @msg compound paths can only contain paths составные контуры могут содержать только контуры @msg compressed (RLE) bitmaps can't be read yet сжатые (RLE) растровые изображения пока не читаются @msg damaged повреждено @@ -5649,6 +5674,7 @@ lighting Light Свет Ruby… Руби… Ruby Руби Geometric Guides Геометрические направляющие + Optical Effects Оптические эффекты Geometric Patterns Геометрические узоры Grit Textures Зернистые текстуры Ease In Плавное начало @@ -5693,6 +5719,9 @@ axis Y Axis Ось Y @msg `dither` must be true or false `dither` должно быть true или false Perceptual Перцепционный @msg `method` must be classic or perceptual `method` должен быть classic или perceptual +@msg `spread` must be pad, repeat or reflect `spread` должен быть pad, repeat или reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` должен быть целым числом от 1 до {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` применяется к линейным, радиальным, коническим и ромбовидным градиентам # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Установить «{name}», которому нужен большой вычислительный бюджет? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Каждый запуск может длиться до {minutes} минут и выполнять в {times} раз больше работы, чем разрешено другим модулям. Он работает в фоне, и его можно отменить. @@ -5942,6 +5971,11 @@ axis Y Axis Ось Y Grass Fan Веер для травы Pattern Borders Узорные каймы Scatter Shapes Рассеянные фигуры + Dry Media Сухие материалы + Chalk Grain Меловое зерно + Graphite Pencil Графитный карандаш + Wax Crayon Восковой мелок + Charcoal Dust Угольная пыль Save Brush Library… Сохранить библиотеку кистей… @msg `{_1}` has no brushes to apply В `{_1}` нет кистей для применения @msg missing `library` (see brush.libraries) не указан `library` (см. brush.libraries) @@ -7080,3 +7114,8 @@ shape Open Открытый @msg Some islands could not be joined to the area around the art Некоторые островки не удалось соединить с областью вокруг графики @msg `width` must be from {lo} to {hi} pt `width` должно быть от {lo} до {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` должно быть целым числом от 1 до {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Рамка и линии выборки… + Weed Box and Lines Рамка и линии выборки + Margin Поле + Box Рамка + Swatch Образец diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ce20b6c72d..3c7e078fed 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -881,6 +881,17 @@ font-technology Variable Variabla The selected character's font is not available. Typsnittet för det markerade tecknet är inte tillgängligt. No alternates for the selected character. Inga alternativ för det markerade tecknet. Double-click to replace Dubbelklicka för att ersätta + Select one character with the Type tool first. Markera först ett tecken med textverktyget. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} är en glyf i {font}, inte i det typsnitt den markerade texten är satt i ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hör till {base} (U+{base_code}) {place}; din markering är {sel} (U+{code}). + at the start of a word i början av ett ord + in the middle of a word mitt i ett ord + at the end of a word i slutet av ett ord + standing alone när det står ensamt + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} är inget alternativ till {sel} (U+{code}) som det står i sitt ord. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dubbelklicka för att infoga {base} (U+{code}) i stället: en presentationsform ansluter inte, bokstaven gör det. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) infogades i stället för en presentationsform: typsnittet ansluter det till grannarna efter position. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion ritar den här glyfen här, så den ersätter tecknets glyf och ansluter kanske inte till grannteckenen. Double-click to insert Dubbelklicka för att infoga Double-click to rename Dubbelklicka för att byta namn Double-click to target the contents Dubbelklicka för att välja innehållet som mål @@ -1935,6 +1946,8 @@ font-technology Variable Variabla Metallic Gradients Metalliska gradienter Meters Meter Method: Metod: + Pad Förläng + Repeats Upprepningar Millimeters Millimeter Minify Minifiera Minimal Minimal @@ -2299,6 +2312,10 @@ font-technology Variable Variabla Arch Valv @msg select point type or type on a path markera punkttext eller text på bana @msg type on a path in a thread cannot use Dynamic Text: {frames} text på bana i länkad text kan inte använda dynamisk text: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} text på en öppen eller sammansatt bana kan inte centreras överst eller nederst: {open} +@msg type on a path with no length or no height cannot be centered: {flat} text på en bana utan längd eller utan höjd kan inte centreras: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} text på bana i länkad text kan inte centreras överst eller nederst: {frames} +@msg select type on a closed path markera text på en sluten bana Optimize for fast web view Optimera för snabb webbvisning Optimized Optimerad Options Alternativ @@ -3336,6 +3353,9 @@ digits Malayalam Malayalam Split Path Dela bana spot dekor Spot Color Dekorfärg + Spot Color Objects Objekt med dekorfärger + Pattern Objects Objekt med mönster + Gradient Objects Objekt med gradienter Spot Colors Dekorfärger Spot Colors… Dekorfärger… Spread: Spridning: @@ -3644,6 +3664,8 @@ digits Malayalam Malayalam Type on a Path Text på bana Type on a Path Options Alternativ för text på bana Type on a Path Options… Alternativ för text på bana… + Center on Top Centrera överst + Center on Bottom Centrera nederst Type on a Path Tool Verktyg för text på bana Type Orientation Textorientering Type stays text, and every stroke is a line with its lineweight and dashes. Text förblir text, och varje kontur blir en linje med sin linjetjocklek och sina streck. @@ -4304,6 +4326,7 @@ axis Both Båda @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` är inte en färg som "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: högst {MAX_TABLE} färger @msg command `{0}` is not available right now: {1} kommandot `{0}` är inte tillgängligt just nu: {1} +@msg command `{id}` is not available right now kommandot `{id}` är inte tillgängligt just nu @msg compound paths can only contain paths sammansatta banor kan bara innehålla banor @msg compressed (RLE) bitmaps can't be read yet komprimerade (RLE) bitmappar kan inte läsas än @msg damaged skadad @@ -5841,6 +5864,8 @@ theme Appearance Utseende @msg the screen color sampler is already open färghämtningen från skärmen är redan öppen @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS det här är en PostScript-fil (en .ai sparad i ett äldre format eller utan PDF-kompatibilitet, eller en EPS-fil), inte en PDF: Fil › Öppna och Fil › Placera läser den som EPS @msg this system has no screen color sampler det här systemet saknar färghämtning från skärmen +@msg the screen color sampler did not answer färghämtningen från skärmen svarade inte +@msg the screen color sampler failed färghämtningen från skärmen misslyckades @msg the image can't be read as a texture bilden kan inte läsas som en textur # User Interface › Accent Color @@ -6062,6 +6087,7 @@ font-script Japanese Japanska Ruby… Ruby… Ruby Ruby Geometric Guides Geometriska stödlinjer + Optical Effects Optiska effekter Geometric Patterns Geometriska mönster Grit Textures Korniga texturer Ease In Långsam start @@ -6104,6 +6130,9 @@ axis Y Axis Y-axel @msg `dither` applies to gradient fills (paint.setFill) `dither` gäller gradientfyllningar (paint.setFill) @msg `dither` must be true or false `dither` måste vara true eller false @msg `method` must be classic or perceptual `method` måste vara classic eller perceptual +@msg `spread` must be pad, repeat or reflect `spread` måste vara pad, repeat eller reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` måste vara ett heltal från 1 till {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` gäller för linjära, radiella och koniska gradienter och rombgradienter # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Installera ”{name}”, som begär en stor beräkningsbudget? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Varje körning kan ta upp till {minutes} minuter och göra {times} gånger så mycket arbete som andra plugins får. Den körs i bakgrunden och du kan avbryta den. @@ -6348,6 +6377,11 @@ axis Y Axis Y-axel Grass Fan Grässolfjäder Pattern Borders Mönsterbårder Scatter Shapes Spridningsformer + Dry Media Torra medier + Chalk Grain Kritkorn + Graphite Pencil Grafitpenna + Wax Crayon Vaxkrita + Charcoal Dust Koldamm Save Brush Library… Spara penselbibliotek… @msg `{_1}` has no brushes to apply `{_1}` har inga penslar att använda @msg missing `library` (see brush.libraries) `library` saknas (se brush.libraries) @@ -8360,7 +8394,7 @@ digits Hindi Hindi @msg width must be positive width måste vara positiv @msg width out of range bredden ligger utanför tillåtet omfång @msg width/height must be > 0 bredd/höjd måste vara > 0 -@msg width/height must be positive and within the canvas bredd/höjd måste vara positiva och inom arbetsytan +@msg width and height must be more than 0 and at most {limit} bredd och höjd måste vara större än 0 och högst {limit} @msg x and y must be finite numbers x och y måste vara ändliga tal @msg x and y must be given together x och y måste anges tillsammans @msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone behöver ett ankare: docka bredvid en panel med anchor eller i en ny dockkolumn med side (Start eller End) @@ -8469,3 +8503,8 @@ digits Hindi Hindi @msg Some islands could not be joined to the area around the art Vissa öar kunde inte förbindas med området runt teckningen @msg `width` must be from {lo} to {hi} pt `width` måste vara från {lo} till {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` måste vara ett heltal från 1 till {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Rensningsram och rensningslinjer… + Weed Box and Lines Rensningsram och rensningslinjer + Margin Marginal + Box Ram + Swatch Färgruta diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index ade1efa8be..14193a1445 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -184,6 +184,20 @@ fn font_height_reference_labels_are_in_complete_catalogs() { } } +/// The Window › Brush Libraries menu, built from the built-in library table, names each library. +#[test] +fn built_in_brush_library_names_are_in_complete_catalogs() { + let mut bad = vec![]; + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + let missing: Vec<&str> = + vectorcraft_engine::cmd::brushlib::BUILTIN.iter().map(|b| b.name).filter(|l| lang.catalog().plain(l).is_none()).collect(); + if !missing.is_empty() { + bad.push(format!("{} is missing {missing:?}", lang.code)); + } + } + assert!(bad.is_empty(), "{}", bad.join("\n")); +} + /// Preference choices and the marquee's E hint share the same translated labels. #[test] fn marquee_selection_labels_are_in_complete_catalogs() { @@ -225,6 +239,28 @@ fn control_bar_panel_menu_labels_are_in_complete_catalogs() { } } +/// The titles of Document Info's Spot Color Objects, Pattern Objects and Gradient Objects +/// categories, which the panel, its menu and the Objects category's counts show. +#[test] +fn document_info_object_category_titles_are_in_complete_catalogs() { + let mut session = vectorcraft_engine::Session::new(); + session.execute("file.new", &serde_json::json!({"width": 100, "height": 100})).unwrap(); + let info = session.execute("document.info", &serde_json::json!({})).unwrap(); + let titles: Vec<&str> = info["sections"] + .as_array() + .unwrap() + .iter() + .filter(|s| matches!(s["id"].as_str(), Some("spotColors" | "patterns" | "gradients"))) + .filter_map(|s| s["title"].as_str()) + .collect(); + assert_eq!(titles, ["Spot Color Objects", "Pattern Objects", "Gradient Objects"]); + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for title in &titles { + assert!(lang.catalog().plain(title).is_some(), "{} is missing {title}", lang.code); + } + } +} + /// The Character panel's Anti-aliasing Method label and its choices, with the Sharp method's /// own `antiAlias` row apart from the plain "Sharp" corner row. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index db3c4501b7..939dadc352 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -873,6 +873,17 @@ font-technology Variable Değişken The selected character's font is not available. Seçili karakterin yazı tipi kullanılamıyor. No alternates for the selected character. Seçili karakter için alternatif yok. Double-click to replace Değiştirmek için çift tıklayın + Select one character with the Type tool first. Önce Yazım aracıyla bir karakter seçin. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph}, seçili metnin yazı tipine ({text_font}) değil, {font} yazı tipine ait bir glif. + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph}, {base} (U+{base_code}) karakterine aittir ({place}); seçiminiz {sel} (U+{code}). + at the start of a word kelimenin başında + in the middle of a word kelimenin ortasında + at the end of a word kelimenin sonunda + standing alone tek başına dururken + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}, kendi kelimesinde durduğu hâliyle {sel} (U+{code}) karakterinin alternatifi değil. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Bunun yerine {base} (U+{code}) eklemek için çift tıklayın: sunum biçimi bağlanmaz, harf bağlanır. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Sunum biçimi yerine {base} (U+{code}) eklendi: yazı tipi onu konumuna göre komşularına bağlar. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Hiçbir OpenType özelliği bu glifi burada çizmez; bu yüzden karakterin glifinin yerine geçer ve komşu karakterlere bağlanmayabilir. Double-click to insert Eklemek için çift tıklayın Double-click to rename Yeniden adlandırmak için çift tıklayın Double-click to target the contents İçeriği hedeflemek için çift tıklayın @@ -1828,6 +1839,8 @@ font-technology Variable Değişken Metallic Gradients Metalik Degradeler Meters Metre Method: Yöntem: + Pad Uzat + Repeats Tekrar sayısı Millimeters Milimetre Minify Küçült Minimal Asgari @@ -2171,6 +2184,10 @@ shape Open Açık Arch Kemer @msg select point type or type on a path nokta metni veya yol üzerindeki yazıyı seçin @msg type on a path in a thread cannot use Dynamic Text: {frames} zincirli metindeki yol üzerindeki yazı dinamik metin kullanamaz: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} açık veya bileşik bir yol üzerindeki yazı üstte veya altta ortalanamaz: {open} +@msg type on a path with no length or no height cannot be centered: {flat} uzunluğu ya da yüksekliği olmayan yol üzerindeki yazı ortalanamaz: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} zincirli metindeki yol üzerindeki yazı üstte veya altta ortalanamaz: {frames} +@msg select type on a closed path kapalı bir yol üzerindeki yazıyı seçin Optimize for fast web view Hızlı web görünümü için optimize edin Optimized Optimize edilmiş Options Seçenekler @@ -3237,6 +3254,9 @@ digits Malayalam Malayalam Split Path Bölünmüş Yol spot nokta Spot Color Nokta Rengi + Spot Color Objects Spot Renkli Nesneler + Pattern Objects Desenli Nesneler + Gradient Objects Degradeli Nesneler Spot Colors Nokta Renkleri Spot Colors… Nokta Renkleri… Spread: Yayılma: @@ -3532,6 +3552,8 @@ digits Malayalam Malayalam Type on a Path Bir Yola Yazın Type on a Path Options Yol Seçenekleri Üzerine Yazma Type on a Path Options… Yol Seçenekleri Üzerine Yazma… + Center on Top Üstte ortala + Center on Bottom Altta ortala Type on a Path Tool Yola Yazma Aracı Type Orientation Tip Yönü Type stays text, and every stroke is a line with its lineweight and dashes. Yazı metin olarak kalır ve her kontur, çizgi kalınlığı ve tireleriyle birlikte bir çizgidir. @@ -4135,6 +4157,8 @@ axis Both Her İkisi @msg the Color Picker is not open Renk Seçici açık değil @msg the screen color sampler is already open ekran rengi örnekleyici zaten açık @msg this system has no screen color sampler bu sistemin ekran rengi örnekleyicisi yok +@msg the screen color sampler did not answer ekran rengi örnekleyici yanıt vermedi +@msg the screen color sampler failed ekran rengi örnekleyici başarısız oldu @msg bad colour `{hex}` (#rrggbb) kötü renk `{hex}` (#rrggbb) @msg bad page tree kötü sayfa ağacı @msg bad shortcut kötü kısayol @@ -4166,6 +4190,7 @@ axis Both Her İkisi @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}`, "#ff8800" gibi bir renk değil @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: en fazla {MAX_TABLE} renginde @msg command `{0}` is not available right now: {1} `{0}` komutu şu anda kullanılamıyor: {1} +@msg command `{id}` is not available right now `{id}` komutu şu anda kullanılamıyor @msg compound paths can only contain paths bileşik yollar yalnızca yollar içerebilir @msg compressed (RLE) bitmaps can't be read yet sıkıştırılmış (RLE) bitmapler henüz okunamıyor @msg damaged hasarlı @@ -6035,9 +6060,13 @@ axis Y Axis Y ekseni Snap to Tangent Teğete Yasla Snap to Perpendicular Dikeye Yasla Geometric Guides Geometrik Kılavuzlar + Optical Effects Optik Efektler Geometric Patterns Geometrik Desenler Grit Textures Grenli Dokular @msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır +@msg `spread` must be pad, repeat or reflect `spread` pad, repeat veya reflect olmalıdır +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 1 ile {MAX_REPEATS} arasında bir tam sayı olmalıdır +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` doğrusal, radyal, konik ve elmas degradeler için geçerlidir # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Büyük bir hesaplama bütçesi isteyen “{name}” yüklensin mi? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Her çalıştırma {minutes} dakikaya kadar sürebilir ve diğer eklentilere izin verilen işin {times} katını yapabilir. Arka planda çalışır ve iptal edebilirsiniz. @@ -6287,6 +6316,11 @@ axis Y Axis Y ekseni Grass Fan Çim Yelpazesi Pattern Borders Desen Kenarlıkları Scatter Shapes Dağılım Şekilleri + Dry Media Kuru Teknikler + Chalk Grain Tebeşir Dokusu + Graphite Pencil Grafit Kalem + Wax Crayon Mum Boya + Charcoal Dust Kömür Tozu Save Brush Library… Fırça Kitaplığını Kaydet… @msg `{_1}` has no brushes to apply `{_1}` içinde uygulanacak fırça yok @msg missing `library` (see brush.libraries) `library` eksik (bkz. brush.libraries) @@ -6947,3 +6981,8 @@ breakText Glyphs Glifler @msg Some islands could not be joined to the area around the art Bazı adalar çizimin çevresindeki alana bağlanamadı @msg `width` must be from {lo} to {hi} pt `width` {lo} ile {hi} pt arasında olmalıdır @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 ile {MAX_BRIDGES_PER_ISLAND} arasında bir tam sayı olmalıdır + Weed Box and Lines… Ayıklama Kutusu ve Çizgileri… + Weed Box and Lines Ayıklama Kutusu ve Çizgileri + Margin Kenar Boşluğu + Box Kutu + Swatch Renk Örneği diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 151a886cf9..0b6d6abd1e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -896,6 +896,17 @@ font-technology Variable Варіативні The selected character's font is not available. Шрифт виділеного символу недоступний. No alternates for the selected character. Виділений символ не має альтернатив. Double-click to replace Двічі клацніть для заміни + Select one character with the Type tool first. Спершу виділіть один символ інструментом «Текст». + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} — гліф шрифту {font}, а не шрифту виділеного тексту ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} належить символу {base} (U+{base_code}) {place}; виділено символ {sel} (U+{code}). + at the start of a word на початку слова + in the middle of a word у середині слова + at the end of a word наприкінці слова + standing alone в окремому вигляді + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не є альтернативою символу {sel} (U+{code}) у тому вигляді, в якому він стоїть у своєму слові. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Двічі клацніть, щоб вставити замість нього {base} (U+{code}): форма подання не з’єднується, літера з’єднується. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Замість форми подання вставлено {base} (U+{code}): шрифт з’єднує його із сусідніми за положенням. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Жодна функція OpenType не малює тут цей гліф, тому він замінює гліф символу й може не з’єднуватися із сусідніми символами. Double-click to insert Двічі клацніть для вставки Double-click to rename Двічі клацніть для перейменування Double-click to target the contents Двічі клацніть, щоб вибрати вміст як об’єкт @@ -2026,6 +2037,8 @@ file-filter Templates Шаблони Metallic Gradients Металеві градієнти Meters Метри Method: Метод: + Pad Продовжити + Repeats Кількість повторів Millimeters Міліметри Minify Мінімізувати Minimal Мінімальний @@ -2390,6 +2403,10 @@ file-filter Templates Шаблони Arch Арка @msg select point type or type on a path виберіть точковий текст або текст за контуром @msg type on a path in a thread cannot use Dynamic Text: {frames} текст за контуром у пов’язаному тексті не може використовувати динамічний текст: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} не можна центрувати вгорі чи внизу текст за незамкненим або складеним контуром: {open} +@msg type on a path with no length or no height cannot be centered: {flat} не можна центрувати текст за контуром без довжини або без висоти: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} не можна центрувати вгорі чи внизу текст за контуром у пов’язаному тексті: {frames} +@msg select type on a closed path виділіть текст за замкненим контуром Optimize for fast web view Оптимізувати для швидкого перегляду web Optimized Оптимізовано Options Параметри @@ -3452,6 +3469,9 @@ effect Points Точки Split Path Розділити контур spot плашковий Spot Color Плашковий колір + Spot Color Objects Об’єкти з плашковими кольорами + Pattern Objects Об’єкти з візерунками + Gradient Objects Об’єкти з градієнтами Spot Colors Плашкові кольори Spot Colors… Плашкові кольори… Spread: Розворот: @@ -3762,6 +3782,8 @@ effect Points Точки Type on a Path Текст за контуром Type on a Path Options Параметри тексту за контуром Type on a Path Options… Параметри тексту за контуром… + Center on Top Центрувати вгорі + Center on Bottom Центрувати внизу Type on a Path Tool Інструмент «Текст за контуром» Type Orientation Орієнтація тексту Type stays text, and every stroke is a line with its lineweight and dashes. Текст залишається текстом, і кожне обведення — лінія з її товщиною та штрихами. @@ -4407,6 +4429,8 @@ axis Both Обидві @msg the Smooth slider is not open повзунок «Згладити» не відкрито @msg the screen color sampler is already open засіб вибору кольору з екрана вже відкритий @msg this system has no screen color sampler у цій системі немає засобу вибору кольору з екрана +@msg the screen color sampler did not answer засіб вибору кольору з екрана не відповів +@msg the screen color sampler failed у засобі вибору кольору з екрана сталася помилка @msg bad colour `{hex}` (#rrggbb) неприпустимий колір `{hex}` (#rrggbb) @msg bad page tree неприпустиме дерево сторінок @msg bad shortcut неприпустиме поєднання клавіш @@ -4436,6 +4460,7 @@ axis Both Обидві @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` не є кольором, наприклад "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: не більше {MAX_TABLE} кольорів @msg command `{0}` is not available right now: {1} команда `{0}` зараз недоступна: {1} +@msg command `{id}` is not available right now команда `{id}` зараз недоступна @msg compound paths can only contain paths складені контури можуть містити лише контури @msg compressed (RLE) bitmaps can't be read yet стислі (RLE) растрові зображення поки не читаються @msg damaged пошкоджено @@ -5643,6 +5668,7 @@ lighting Light Світло Ruby… Рубі… Ruby Рубі Geometric Guides Геометричні напрямні + Optical Effects Оптичні ефекти Geometric Patterns Геометричні візерунки Grit Textures Зернисті текстури Ease In Плавний початок @@ -5687,6 +5713,9 @@ axis Y Axis Вісь Y @msg `dither` must be true or false `dither` має бути true або false Perceptual Перцептивний @msg `method` must be classic or perceptual `method` має бути classic або perceptual +@msg `spread` must be pad, repeat or reflect `spread` має бути pad, repeat або reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` має бути цілим числом від 1 до {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` застосовується до лінійних, радіальних, конічних і ромбоподібних градієнтів # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Встановити «{name}», якому потрібен великий обчислювальний бюджет? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Кожен запуск може тривати до {minutes} хвилин і виконувати в {times} разів більше роботи, ніж дозволено іншим модулям. Він працює у фоні, і його можна скасувати. @@ -5936,6 +5965,11 @@ axis Y Axis Вісь Y Grass Fan Віяло для трави Pattern Borders Візерункові облямівки Scatter Shapes Розсіяні фігури + Dry Media Сухі матеріали + Chalk Grain Крейдяне зерно + Graphite Pencil Графітний олівець + Wax Crayon Восковий олівець + Charcoal Dust Вугільний пил Save Brush Library… Зберегти бібліотеку пензлів… @msg `{_1}` has no brushes to apply У `{_1}` немає пензлів для застосування @msg missing `library` (see brush.libraries) не вказано `library` (див. brush.libraries) @@ -6600,3 +6634,8 @@ breakText Glyphs Гліфи @msg Some islands could not be joined to the area around the art Деякі острівці не вдалося з’єднати з областю навколо графіки @msg `width` must be from {lo} to {hi} pt `width` має бути від {lo} до {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` має бути цілим числом від 1 до {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Рамка й лінії вибірки… + Weed Box and Lines Рамка й лінії вибірки + Margin Поле + Box Рамка + Swatch Зразок diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f3b505c648..0a2145701f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1051,6 +1051,17 @@ font-technology Variable Biến thiên The selected character's font is not available. Phông chữ của ký tự đã chọn không khả dụng. No alternates for the selected character. Ký tự đã chọn không có glyph thay thế. Double-click to replace Bấm đúp để thay thế + Select one character with the Type tool first. Trước hết hãy chọn một ký tự bằng công cụ Chữ. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} là glyph của {font}, không phải của phông chữ mà văn bản đã chọn đang dùng ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} thuộc về {base} (U+{base_code}) {place}; ký tự bạn chọn là {sel} (U+{code}). + at the start of a word ở đầu từ + in the middle of a word ở giữa từ + at the end of a word ở cuối từ + standing alone khi đứng riêng + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} không phải glyph thay thế của {sel} (U+{code}) như ký tự đó đang đứng trong từ của nó. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Bấm đúp để chèn {base} (U+{code}) thay thế: dạng trình bày không nối, còn chữ cái thì nối. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Đã chèn {base} (U+{code}) thay cho dạng trình bày: phông chữ nối nó với các ký tự bên cạnh theo vị trí. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Không có tính năng OpenType nào vẽ glyph này ở đây, nên nó thay glyph của ký tự và có thể không nối với các ký tự bên cạnh. Double-click to insert Bấm đúp để chèn Double-click to rename Bấm đúp để đổi tên Double-click to target the contents Bấm đúp để chọn nội dung @@ -2334,6 +2345,8 @@ digits Hindi Hindi Meters Mét Method Phương pháp Method: Phương pháp: + Pad Kéo dài + Repeats Số lần lặp Mezzotint Mezzotint Mezzotint… Mezzotint… Millimeters Milimét @@ -2675,6 +2688,10 @@ digits Hindi Hindi Arch Vòm @msg select point type or type on a path chọn chữ điểm hoặc chữ trên đường dẫn @msg type on a path in a thread cannot use Dynamic Text: {frames} chữ trên đường dẫn trong văn bản chuỗi không thể dùng văn bản động: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} không thể căn giữa ở trên hoặc ở dưới chữ trên đường dẫn mở hoặc đường dẫn phức: {open} +@msg type on a path with no length or no height cannot be centered: {flat} không thể căn giữa chữ trên đường dẫn không có độ dài hoặc không có chiều cao: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} không thể căn giữa ở trên hoặc ở dưới chữ trên đường dẫn trong văn bản chuỗi: {frames} +@msg select type on a closed path hãy chọn chữ trên một đường dẫn khép kín Optimize for fast web view Tối ưu để xem web nhanh Optimized Đã tối ưu Options Tùy chọn @@ -3798,6 +3815,9 @@ mark-style Japanese Kiểu Nhật Split Path Tách đường dẫn spot đặc biệt Spot Color Màu đặc biệt + Spot Color Objects Đối tượng có màu đặc biệt + Pattern Objects Đối tượng có họa tiết + Gradient Objects Đối tượng có chuyển sắc Spot Colors Màu đặc biệt Spot Colors… Màu đặc biệt… Spray Radius Bán kính phun @@ -4145,6 +4165,8 @@ mark-style Japanese Kiểu Nhật Type on a Path Chữ trên đường dẫn Type on a Path Options Tùy chọn chữ trên đường dẫn Type on a Path Options… Tùy chọn chữ trên đường dẫn… + Center on Top Căn giữa ở trên + Center on Bottom Căn giữa ở dưới Type on a Path Tool Công cụ Chữ trên đường dẫn Type Orientation Hướng chữ Type stays text, and every stroke is a line with its lineweight and dashes. Chữ vẫn là văn bản, và mỗi nét là đường thẳng với độ dày và nét đứt. @@ -4573,6 +4595,8 @@ theme Appearance Giao diện @msg the Smooth slider is not open Thanh trượt Làm mượt chưa được mở @msg the screen color sampler is already open công cụ lấy mẫu màu trên màn hình đã được mở @msg this system has no screen color sampler hệ thống này không có công cụ lấy mẫu màu trên màn hình +@msg the screen color sampler did not answer công cụ lấy mẫu màu trên màn hình không phản hồi +@msg the screen color sampler failed công cụ lấy mẫu màu trên màn hình gặp lỗi @msg bad colour `{hex}` (#rrggbb) màu không hợp lệ `{hex}` (#rrggbb) @msg bad page tree cây trang bị lỗi @msg bad shortcut phím tắt không hợp lệ @@ -4609,6 +4633,7 @@ theme Appearance Giao diện @msg column must be true or a number from 1 column phải là true hoặc số từ 1 @msg Combine Slices: the slices have no bounds Kết hợp lát cắt: các lát cắt không có giới hạn @msg command `{0}` is not available right now: {1} lệnh `{0}` hiện không khả dụng: {1} +@msg command `{id}` is not available right now lệnh `{id}` hiện không khả dụng @msg compound paths can only contain paths đường dẫn phức chỉ có thể chứa đường dẫn @msg compressed (RLE) bitmaps can't be read yet bitmap nén (RLE) chưa thể đọc được @msg Convert to Artboards: selection has no area Chuyển thành bảng vẽ: vùng chọn không có diện tích @@ -5644,6 +5669,7 @@ theme Appearance Giao diện Ruby… Ruby… Ruby Ruby Geometric Guides Đường dẫn hình học + Optical Effects Hiệu ứng quang học Geometric Patterns Họa tiết hình học Grit Textures Kết cấu hạt Ease In Chậm lúc đầu @@ -5688,6 +5714,9 @@ axis Y Axis Trục Y @msg `dither` must be true or false `dither` phải là true hoặc false Perceptual Theo cảm nhận @msg `method` must be classic or perceptual `method` phải là classic hoặc perceptual +@msg `spread` must be pad, repeat or reflect `spread` phải là pad, repeat hoặc reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` phải là số nguyên từ 1 đến {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` áp dụng cho chuyển sắc tuyến tính, tỏa tròn, hình nón và hình thoi # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Cài đặt “{name}”, plug-in yêu cầu ngân sách tính toán lớn? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Mỗi lần chạy có thể mất tới {minutes} phút và làm khối lượng việc gấp {times} lần mức cho phép với các plug-in khác. Plug-in chạy trong nền và bạn có thể hủy. @@ -5937,6 +5966,11 @@ axis Y Axis Trục Y Grass Fan Cọ quạt vẽ cỏ Pattern Borders Viền họa tiết Scatter Shapes Hình phân tán + Dry Media Chất liệu khô + Chalk Grain Hạt phấn + Graphite Pencil Bút chì than chì + Wax Crayon Bút sáp màu + Charcoal Dust Bụi than Save Brush Library… Lưu thư viện cọ… @msg `{_1}` has no brushes to apply `{_1}` không có cọ nào để áp dụng @msg missing `library` (see brush.libraries) thiếu `library` (xem brush.libraries) @@ -6601,3 +6635,8 @@ breakText Glyphs Glyph @msg Some islands could not be joined to the area around the art Không thể nối một số đảo với vùng xung quanh hình vẽ @msg `width` must be from {lo} to {hi} pt `width` phải từ {lo} đến {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` phải là số nguyên từ 1 đến {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Khung và đường bóc phần thừa… + Weed Box and Lines Khung và đường bóc phần thừa + Margin Lề + Box Khung + Swatch Mẫu màu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c7219b9448..131f6bd8f2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -836,6 +836,17 @@ antiAlias Sharp 锐利 The selected character's font is not available. 所选字符的字体不可用。 No alternates for the selected character. 所选字符没有替代字。 Double-click to replace 双击以替换 + Select one character with the Type tool first. 请先用文字工具选择一个字符。 + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} 是 {font} 的字形,不是所选文本所用字体({text_font})的字形。 + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} 属于 {base}(U+{base_code}){place};您选择的是 {sel}(U+{code})。 + at the start of a word 位于词首 + in the middle of a word 位于词中 + at the end of a word 位于词尾 + standing alone 单独出现时 + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} 不是 {sel}(U+{code})在其单词中所处位置的替代字。 + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 双击以改为插入 {base}(U+{code}):表现形式不会连接,字母会连接。 + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 已插入 {base}(U+{code})以代替表现形式:字体会按位置将其与相邻字符连接。 + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 没有任何 OpenType 功能会在此处绘制该字形,因此它会替换该字符的字形,并且可能无法与相邻字符连接。 Double-click to insert 双击以插入 Double-click to rename 双击以重命名 Double-click to target the contents 双击以指定内容 @@ -1903,6 +1914,8 @@ file-filter Templates 模板 Metallic Gradients 金属渐变 Meters 米 Method: 方法: + Pad 延伸 + Repeats 重复次数 Millimeters 毫米 Minify 压缩 Minimal 最少 @@ -2258,6 +2271,10 @@ file-filter Templates 模板 Arch 拱形 @msg select point type or type on a path 请选择点文字或路径文字 @msg type on a path in a thread cannot use Dynamic Text: {frames} 串接文本中的路径文字不能使用动态文本:{frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} 开放路径或复合路径上的路径文字无法在顶部或底部居中:{open} +@msg type on a path with no length or no height cannot be centered: {flat} 路径没有长度或没有高度时,其路径文字无法居中:{flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 串接文本中的路径文字无法在顶部或底部居中:{frames} +@msg select type on a closed path 请选择闭合路径上的路径文字 Optimize for fast web view 优化快速 Web 查看 Optimized 优化 Options 选项 @@ -3279,6 +3296,9 @@ digits Malayalam 马拉雅拉姆数字 Split Path 分割路径 spot 专色 Spot Color 专色 + Spot Color Objects 使用专色的对象 + Pattern Objects 使用图案的对象 + Gradient Objects 使用渐变的对象 Spot Colors 专色 Spot Colors… 专色… Spread: 扩散: @@ -3589,6 +3609,8 @@ digits Malayalam 马拉雅拉姆数字 Type on a Path 路径文字 Type on a Path Options 路径文字选项 Type on a Path Options… 路径文字选项… + Center on Top 在顶部居中 + Center on Bottom 在底部居中 Type on a Path Tool 路径文字工具 Type Orientation 文字方向 Type stays text, and every stroke is a line with its lineweight and dashes. 文字维持为文字,每个描边都是带有线宽与虚线的线条。 @@ -4290,6 +4312,7 @@ imageTrace Outline 轮廓 @msg nothing to distort 没有可扭曲的内容 @msg unknown option `{path}.{k}` 未知选项 `{path}.{k}` @msg command `{0}` is not available right now: {1} 命令 `{0}` 当前不可用:{1} +@msg command `{id}` is not available right now 命令 `{id}` 当前不可用 @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` 中出现内部错误:{msg} (文档保持原样;请报告此错误) @msg invalid parameters for `{cmd}`: {msg} `{cmd}` 的参数无效:{msg} @msg no active document 没有活动文档 @@ -4475,6 +4498,8 @@ imageTrace Outline 轮廓 @msg the Smooth slider is not open 平滑滑块未打开 @msg the screen color sampler is already open 屏幕取色器已打开 @msg this system has no screen color sampler 此系统没有屏幕取色器 +@msg the screen color sampler did not answer 屏幕取色器没有响应 +@msg the screen color sampler failed 屏幕取色器出错 @msg Corners edits the corners of a path 转角用于编辑路径的转角 @msg give corners of the path: anchors where its sides meet at an angle 请给出路径的转角:其各边以一定角度相交处的锚点 @msg select one path, or give its `id` 请选择一条路径,或给出其 `id` @@ -5877,6 +5902,7 @@ lighting Light 光源 Ruby… 注音… Ruby 注音 Geometric Guides 几何参考线 + Optical Effects 光学效果 Geometric Patterns 几何图案 Grit Textures 颗粒纹理 Ease In 缓入 @@ -5920,6 +5946,9 @@ axis Y Axis Y 轴 @msg `dither` applies to gradient fills (paint.setFill) `dither` 适用于渐变填色 (paint.setFill) @msg `dither` must be true or false `dither` 必须是 true 或 false @msg `method` must be classic or perceptual `method` 必须是 classic 或 perceptual +@msg `spread` must be pad, repeat or reflect `spread` 必须是 pad、repeat 或 reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 必须是 1 到 {MAX_REPEATS} 之间的整数 +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` 适用于线性、径向、圆锥和菱形渐变 # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? 要安装需要大量计算预算的“{name}”吗? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 每次运行最多可能需要 {minutes} 分钟,并可完成其他插件所允许工作量的 {times} 倍。它在后台运行,可以随时取消。 @@ -6169,6 +6198,11 @@ axis Y Axis Y 轴 Grass Fan 画草扇形笔 Pattern Borders 图案边框 Scatter Shapes 散布形状 + Dry Media 干性画材 + Chalk Grain 粉笔颗粒 + Graphite Pencil 石墨铅笔 + Wax Crayon 蜡笔 + Charcoal Dust 炭粉 Save Brush Library… 保存画笔库… @msg `{_1}` has no brushes to apply `{_1}` 中没有可应用的画笔 @msg missing `library` (see brush.libraries) 缺少 `library`(请参阅 brush.libraries) @@ -6829,3 +6863,8 @@ breakText Glyphs 字形 @msg Some islands could not be joined to the area around the art 部分孤岛无法连接到图稿周围的区域 @msg `width` must be from {lo} to {hi} pt `width` 必须介于 {lo} 到 {hi} pt 之间 @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必须是 1 到 {MAX_BRIDGES_PER_ISLAND} 之间的整数 + Weed Box and Lines… 除废框和除废线… + Weed Box and Lines 除废框和除废线 + Margin 边距 + Box 框 + Swatch 色板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1c753576a5..feba6fd9df 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -864,6 +864,17 @@ font-technology Variable 可變 The selected character's font is not available. 選取字元的字體無法使用。 No alternates for the selected character. 選取的字元沒有替代字。 Double-click to replace 按兩下以取代 + Select one character with the Type tool first. 請先用文字工具選取一個字元。 + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} 是 {font} 的字形,不是所選文字所用字體({text_font})的字形。 + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} 屬於 {base}(U+{base_code}){place};您選取的是 {sel}(U+{code})。 + at the start of a word 位於詞首 + in the middle of a word 位於詞中 + at the end of a word 位於詞尾 + standing alone 單獨出現時 + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} 不是 {sel}(U+{code})在其單字中所處位置的替代字。 + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 按兩下以改為插入 {base}(U+{code}):表現形式不會連接,字母會連接。 + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 已插入 {base}(U+{code})以取代表現形式:字體會依位置將其與相鄰字元連接。 + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 沒有任何 OpenType 功能會在此處繪製該字形,因此它會取代該字元的字形,且可能無法與相鄰字元連接。 Double-click to insert 按兩下以插入 Double-click to rename 按兩下以重新命名 Double-click to target the contents 按兩下以指定內容 @@ -2080,6 +2091,7 @@ file-filter Templates 範本 @msg nothing to distort 沒有可扭曲的內容 @msg unknown option `{path}.{k}` 未知的選項 `{path}.{k}` @msg command `{0}` is not available right now: {1} 指令 `{0}` 目前無法使用:{1} +@msg command `{id}` is not available right now 指令 `{id}` 目前無法使用 @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` 發生內部錯誤:{msg} (文件維持原狀;請回報此錯誤) @msg invalid parameters for `{cmd}`: {msg} `{cmd}` 的參數無效:{msg} @msg no active document 沒有作用中的文件 @@ -2268,6 +2280,8 @@ file-filter Templates 範本 @msg the Smooth slider is not open 「平滑」滑桿未開啟 @msg the screen color sampler is already open 螢幕取色器已開啟 @msg this system has no screen color sampler 此系統沒有螢幕取色器 +@msg the screen color sampler did not answer 螢幕取色器沒有回應 +@msg the screen color sampler failed 螢幕取色器發生錯誤 @msg Corners edits the corners of a path 「轉角」用於編輯路徑的轉角 @msg give corners of the path: anchors where its sides meet at an angle 請提供路徑的轉角:其各邊以角度相交處的錨點 @msg select one path, or give its `id` 請選取一條路徑,或提供其 `id` @@ -2978,6 +2992,8 @@ file-filter Templates 範本 Metallic Gradients 金屬漸層 Meters 公尺 Method: 方法: + Pad 延伸 + Repeats 重複次數 Millimeters 公釐 Minify 縮減檔案大小 Minimal 最少 @@ -3359,6 +3375,10 @@ file-filter Templates 範本 Arch 拱形 @msg select point type or type on a path 請選取點狀文字或路徑文字 @msg type on a path in a thread cannot use Dynamic Text: {frames} 串連文字中的路徑文字無法使用動態文字:{frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} 開放路徑或複合路徑上的路徑文字無法在頂端或底端置中:{open} +@msg type on a path with no length or no height cannot be centered: {flat} 路徑沒有長度或沒有高度時,其路徑文字無法置中:{flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 串連文字中的路徑文字無法在頂端或底端置中:{frames} +@msg select type on a closed path 請選取封閉路徑上的路徑文字 Optimize for fast web view 最佳化以便快速網頁檢視 Optimized 最佳化 Options 選項 @@ -4422,6 +4442,9 @@ digits Malayalam 馬來亞拉姆數字 Split Path 分割路徑 spot 特別色 Spot Color 特別色 + Spot Color Objects 使用特別色的物件 + Pattern Objects 使用圖樣的物件 + Gradient Objects 使用漸層的物件 Spot Colors 特別色 Spot Colors… 特別色… Spread: 擴散: @@ -4735,6 +4758,8 @@ digits Malayalam 馬來亞拉姆數字 Type on a Path 路徑文字 Type on a Path Options 路徑文字選項 Type on a Path Options… 路徑文字選項… + Center on Top 在頂端置中 + Center on Bottom 在底端置中 Type on a Path Tool 路徑文字工具 Type Orientation 文字方向 Type stays text, and every stroke is a line with its lineweight and dashes. 文字維持為文字,每個筆畫都是帶有線寬與虛線的線條。 @@ -5681,6 +5706,7 @@ lighting Light 光源 Ruby… 注音… Ruby 注音 Geometric Guides 幾何參考線 + Optical Effects 光學效果 Geometric Patterns 幾何圖樣 Grit Textures 顆粒紋理 Ease In 緩入 @@ -5725,6 +5751,9 @@ axis Y Axis Y 軸 @msg `dither` must be true or false `dither` 必須是 true 或 false Perceptual 感知 @msg `method` must be classic or perceptual `method` 必須是 classic 或 perceptual +@msg `spread` must be pad, repeat or reflect `spread` 必須是 pad、repeat 或 reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 必須是 1 到 {MAX_REPEATS} 之間的整數 +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` 適用於線性、放射狀、圓錐和菱形漸層 # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? 要安裝需要大量運算預算的「{name}」嗎? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 每次執行最多可能需要 {minutes} 分鐘,並可完成其他增效模組所允許工作量的 {times} 倍。它會在背景執行,您可以隨時取消。 @@ -5974,6 +6003,11 @@ axis Y Axis Y 軸 Grass Fan 畫草扇形筆 Pattern Borders 圖樣邊框 Scatter Shapes 散佈形狀 + Dry Media 乾性畫材 + Chalk Grain 粉筆顆粒 + Graphite Pencil 石墨鉛筆 + Wax Crayon 蠟筆 + Charcoal Dust 炭粉 Save Brush Library… 儲存筆刷資料庫… @msg `{_1}` has no brushes to apply `{_1}` 中沒有可套用的筆刷 @msg missing `library` (see brush.libraries) 未提供 `library`(請參閱 brush.libraries) @@ -6635,3 +6669,8 @@ breakText Glyphs 字符 @msg Some islands could not be joined to the area around the art 部分孤島無法連接到圖稿周圍的區域 @msg `width` must be from {lo} to {hi} pt `width` 必須介於 {lo} 到 {hi} pt 之間 @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必須是介於 1 到 {MAX_BRIDGES_PER_ISLAND} 的整數 + Weed Box and Lines… 除廢框與除廢線… + Weed Box and Lines 除廢框與除廢線 + Margin 邊距 + Box 框 + Swatch 色票 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index dff4fa0dd2..fb4a106465 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -15,8 +15,12 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs}; mod export; mod open; +mod read_only_save; mod save; +#[cfg(test)] +mod tests_read_only_save; + pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, export_pdf, hide_exports, reveal, save_command_output}; pub(crate) use export::{ export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, @@ -29,6 +33,7 @@ pub use open::{ }; #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; +pub(crate) use save::save_current; pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf}; pub(crate) use save::{export_path, is_web, notes_of, report_saved, show_with_notes, target_path, write_named, write_reporting, write_to}; #[cfg(test)] diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 03e0196267..85ef8ffa0c 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -265,13 +265,19 @@ pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result Result { let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); - let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); + // The folder written into, and its name when the reply leaves it out. + let (folder, hidden) = if id == "assets.export3D" { + (params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(), None) + } else { + app.session.screen_export_folder(id, ¶ms).unwrap_or_default() + }; // Without a folder the files come back in the reply, which the caller waits for. let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty() && id != "assets.export3D"; @@ -279,7 +285,7 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> // Made at once, after the exports and saves started before it. background::wait_all(app); let r = app.run(id, params)?; - return report_exported(app, r, &folder, open_location); + return report_exported(app, r, &folder, hidden.as_deref(), open_location); } let export = app.session.prepare_screen_export(id, ¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?; let label = format!("Exporting {}", fileio::file_name(&folder)); @@ -299,14 +305,15 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> return Ok(json!({ "cancelled": true })); } let r = r.inspect_err(|e| report_write_error(app, true, &folder, e))?; - report_exported(app, r, &folder, open_location) + report_exported(app, r, &folder, hidden.as_deref(), open_location) }) } /// Report the files `r` (the reply of `document.exportForScreens`) in the status bar: the count -/// written into `folder`, else the ZIP or the files downloaded (the web); with `open_location`, -/// the file manager shows the first file written. -fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_location: bool) -> Result { +/// written into `folder` (named `hidden` when the reply leaves it out), else the ZIP or the files +/// downloaded (the web); with `open_location`, the file manager shows the first file written, or +/// the folder for a ZIP whose path the reply leaves out. +fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, hidden: Option<&str>, open_location: bool) -> Result { let count = r["files"].as_array().map_or(0, Vec::len); let warned = r["warnings"].as_array().is_some_and(|w| !w.is_empty()); // Files that came back as bytes: (name, base64). @@ -317,15 +324,22 @@ fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_locati } }; if returned.is_empty() { - let first = r["path"].as_str().or_else(|| r["files"][0].as_str()); - let mut status = crate::i18n::msg_n(count as u64, "Exported {n} file to {folder}", "Exported {n} files to {folder}", &[("folder", folder)]); + let shown = hidden.unwrap_or(folder); + let first = match r["path"].as_str().or_else(|| r["files"][0].as_str()) { + // The reply names the files relative to a folder it leaves out, and a ZIP's files inside it. + Some(_) if hidden.is_some() && r.get("bytes").is_some() => Some(folder.to_string()), + Some(name) if hidden.is_some() => Some(format!("{folder}/{name}")), + first => first.map(str::to_string), + }; + let mut status = crate::i18n::msg_n(count as u64, "Exported {n} file to {folder}", "Exported {n} files to {folder}", &[("folder", shown)]); if warned { - status = saved_status(r["path"].as_str().unwrap_or(folder), &r); + status = saved_status(r["path"].as_str().unwrap_or(shown), &r); } if open_location && let Some(first) = first - && let Err(e) = reveal_path(app, first) + && let Err(e) = reveal_path(app, &first) { + let e = if hidden.is_some() { e.replace(folder, shown) } else { e }; status = format!("{status} ({e})"); } show_with_notes(app, status, notes_of(&r)); diff --git a/crates/ui-egui/src/io/read_only_save.rs b/crates/ui-egui/src/io/read_only_save.rs new file mode 100644 index 0000000000..2927e0c0c6 --- /dev/null +++ b/crates/ui-egui/src/io/read_only_save.rs @@ -0,0 +1,64 @@ +//! The continuation of ordinary File > Save when its own location refuses writes (#6146). +//! It reuses Save As, including extension and overwrite checks, under its own picker entry. + +use std::rc::Rc; + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio::{self, SaveMode, SavePlan}; + +use crate::picks::{self, Entry}; +use crate::{FilePick, VectorcraftApp}; + +pub(super) struct Retry { + path: Option, + pick: FilePick, + params: Value, +} + +impl Retry { + pub(super) fn new(plan: &SavePlan, folder: Option) -> Self { + let name = plan.path.as_deref().map(fileio::file_name).unwrap_or_else(|| plan.name.clone()); + let mut params = json!({ "format": plan.format.id, "options": plan.options }); + if let Some(modified) = plan.modified { + params["modified"] = json!(modified); + } + Self { path: plan.path.clone(), pick: FilePick { name, folder, filters: vec![] }, params } + } + + /// None when this continuation is stale or another picker prevents it. The existing failed-save + /// dialog then stays in charge; there is no queue of deferred Save As requests. + pub(super) fn instead(self, app: &mut VectorcraftApp, uid: u64) -> Option> { + if app.automation_asked() || app.services.pick_save.is_none() || app.picks.is_waiting() || super::save::is_web(app) { + return None; + } + let index = app.session.documents().iter().position(|st| st.uid == uid && st.path == self.path)?; + if app.session.active_index() != Some(index) && app.run("document.activate", json!({ "index": index })).is_err() { + return None; + } + let name = &self.pick.name; + let status = format!("Cannot write to the location of “{name}”; choose where to save it"); + app.status(status.clone()); + let save = Rc::new(move |app: &mut VectorcraftApp| { + // An agent can retarget this document while the sheet is open. Its answer must not + // replace that later choice. The picker itself already checks the document's uid. + if !app.session.active().is_some_and(|st| st.uid == uid && st.path == self.path) { + return Err("cancelled".into()); + } + super::save::save_suggesting(app, SaveMode::SaveAs, &self.params, false, Some(&self.pick)) + }); + let again = save.clone(); + let entry = Entry::Call(Rc::new(move |app| { + if let Err(e) = again(app) { + app.status(e); + } + })); + let r = picks::as_own_entry(app, entry, |app| save(app)); + Some(match r { + Err(e) if e == "cancelled" && app.picks.is_waiting() => { + app.status(status); + Ok(json!({ "pending": "fileDialog" })) + } + r => r, + }) + } +} diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index d9d475e76f..ab11de0abc 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -241,6 +241,31 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result Save alone offers another destination when its own location refuses writes. +/// Save Changes during Close/Quit continues to use [`save`]. +pub(crate) fn save_current(app: &mut VectorcraftApp, p: &Value) -> Result { + save_current_in(app, p, fileio::documents_dialog_folder()) +} + +/// The same entry with a resolved Documents folder (also permits deterministic host-free tests). +pub(super) fn save_current_in(app: &mut VectorcraftApp, p: &Value, folder: Option) -> Result { + remember_view(app); + let first = plan(app, SaveMode::Save, &acknowledged(p))?; + let own = !app.automation_asked() + && !is_web(app) + && p.get("path").is_none_or(Value::is_null) + && first.path.is_some() + && app.session.active().is_some_and(|st| !st.converted && !st.recovered && st.path == first.path && st.format == first.format.id); + if !own { + return save(app, SaveMode::Save, p, true); + } + if let Some(r) = first.path.as_deref().and_then(|path| ask_before_losing(app, path, "file.save", p)) { + return Ok(r); + } + let retry = super::read_only_save::Retry::new(&first, folder); + write_plan(app, first, ("file.save", p.clone()), Some(retry)) +} + /// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the /// engine command's `{path?, format?, options?, svg?}`. /// @@ -253,6 +278,17 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result, /// path?}` while a dialog is open. pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { + save_suggesting(app, mode, p, ask_options, None) +} + +/// Reuse Save As with a captured name and folder, without changing the document before writing. +pub(super) fn save_suggesting( + app: &mut VectorcraftApp, + mode: SaveMode, + p: &Value, + ask_options: bool, + suggestion: Option<&FilePick>, +) -> Result { remember_view(app); // Writing over the file the document was read from is asked about here, not refused. let first = plan(app, mode, &acknowledged(p))?; @@ -264,7 +300,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { return ask_format_options(app, mode, first.format, &path); } - return write_plan(app, first, (save_command(mode), with_path(p, &path))); + return write_plan(app, first, (save_command(mode), with_path(p, &path)), None); } if is_web(app) && ask_options { return Ok(open_options(app, mode.command(), first.format, &first.name, true)); @@ -273,25 +309,35 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo SaveMode::Template => fileio::template_filters(first.format.id), _ => fileio::save_filters(first.format.id), }); - let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters }; - let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?; + let pick = FilePick { + name: suggestion.map_or_else(|| first.name.clone(), |s| s.name.clone()), + folder: suggestion.map_or_else(|| first.folder.clone(), |s| s.folder.clone()), + filters, + }; + let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &pick.name, file_type, first.format, mode))?; + // Only the read-only retry carries options for the original format through Save As. + // A different format must use its own normal options flow instead. + let changed_retry_format = suggestion.is_some() && fileio::format_for_name(&picked).is_some_and(|f| f.id != first.format.id); // The picked file type wins over a `format` param. let mut q = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = q.as_object_mut() { o.remove("format"); + if changed_retry_format { + o.remove("options"); + } o.insert("path".into(), json!(picked)); } let chosen = plan(app, mode, &acknowledged(&q))?; if let Some(r) = chosen.path.as_deref().and_then(|path| ask_before_losing(app, path, save_command(mode), &q)) { return Ok(r); } - if ask && asks_options(mode, chosen.format) { + if (ask || changed_retry_format) && asks_options(mode, chosen.format) { let path = chosen.path.clone().unwrap_or_default(); let r = ask_format_options(app, mode, chosen.format, &path); crate::dialogs::opened_by_panel(app); return r; } - write_plan(app, chosen, (save_command(mode), q)) + write_plan(app, chosen, (save_command(mode), q), None) } /// `p` (an object, else none) with `path`. @@ -471,7 +517,12 @@ pub(crate) fn saved_status(path: &str, r: &Value) -> String { /// A file it writes beside the one at its path that is there already, and that the document's last /// save did not write, is asked about before anything is written; `again` (a command and its params) /// runs the save again once the question's OK allows it ([`ask_to_replace_files`]). -fn write_plan(app: &mut VectorcraftApp, plan: SavePlan, again: (&'static str, Value)) -> Result { +fn write_plan( + app: &mut VectorcraftApp, + plan: SavePlan, + again: (&'static str, Value), + retry: Option, +) -> Result { let uid = app.session.active().ok_or("no document")?.uid; // Saves of one document finish in order. if app.background.busy_with(uid) { @@ -497,28 +548,60 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan, again: (&'static str, Va let enabled = app.session.prefs.background_save && target.is_some(); // The work writes the file; the copy makes it the document's own afterwards. let done = job.clone(); + // Capture the caller before background work leaves the request: it must never open a panel + // or error dialog later, when answering_control has been reset. + let by_control = app.automation_asked(); + let location_refused = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let refused_by_work = location_refused.clone(); + let own_path = target.clone(); + let save_write = app.services.save_write.clone().filter(|_| !is_web(app)); + let save_stream = app.services.save_stream.clone().filter(|_| job.streams() && !is_web(app)); // A native file streams into the file as it is encoded (desktop; not to a download). let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); let (exists, allowed) = checks_replaced(app); let known: Vec = allowed.iter().chain(app.picks.written_beside.get(&uid).into_iter().flatten()).cloned().collect(); let work = move |write: Writer| { - match stream { - Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), - None => job.write_unless( - |paths| { - let path = job.plan().path.as_deref().unwrap_or_default(); - let replaced = replaced_files(exists.as_ref(), path, &known, paths.iter().copied()); - (!replaced.is_empty()).then(|| json!({ "replaces": replaced })) - }, - |p, b| write(p, b).map_err(EngineError::Other), - ), - } - .map_err(|e| e.to_string()) + let mut own_refused = false; + let mut observed = |p: &str, r: vectorcraft_engine::Result<()>| { + own_refused = own_path.as_deref() == Some(p) && matches!(&r, Err(EngineError::ReadOnlyLocation(_))); + r + }; + let check = |paths: &[&str]| { + let path = job.plan().path.as_deref().unwrap_or_default(); + let replaced = replaced_files(exists.as_ref(), path, &known, paths.iter().copied()); + (!replaced.is_empty()).then(|| json!({ "replaces": replaced })) + }; + let r = match (save_stream, save_write, stream) { + (Some(stream), _, _) => job.write_streamed(&mut |p, fill| observed(p, stream(p, fill))), + (_, Some(write), _) => job.write_unless(check, |p, b| observed(p, write(p, b))), + (_, _, Some(stream)) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), + _ => job.write_unless(check, |p, b| write(p, b).map_err(EngineError::Other)), + }; + r.map_err(|e| { + if let EngineError::ReadOnlyLocation(reason) = &e { + refused_by_work.store(own_refused, std::sync::atomic::Ordering::Relaxed); + if !by_control { + return reason.clone(); + } + } + e.to_string() + }) }; let failed = target.clone().unwrap_or_default(); let then = move |app: &mut VectorcraftApp, r: Result| { + if r.is_err() + && location_refused.load(std::sync::atomic::Ordering::Relaxed) + && let Some(retry) = retry + && let Some(r) = retry.instead(app, uid) + { + return r; + } // Not written: the document keeps its file, format and modified state, and the user is told. - let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; + let r = r.inspect_err(|e| { + if !by_control { + report_write_error(app, false, &failed, e); + } + })?; // Nothing written either: asked first. if let Some(files) = r.get("replaces").and_then(Value::as_array) { let files: Vec = files.iter().filter_map(Value::as_str).map(str::to_string).collect(); diff --git a/crates/ui-egui/src/io/tests_read_only_save.rs b/crates/ui-egui/src/io/tests_read_only_save.rs new file mode 100644 index 0000000000..d7cc4647ac --- /dev/null +++ b/crates/ui-egui/src/io/tests_read_only_save.rs @@ -0,0 +1,357 @@ +//! Injected writers and picker answers: no native panel or filesystem permissions are needed. + +use std::io::ErrorKind; +use std::sync::{Arc, Mutex, mpsc}; + +use serde_json::{Value, json}; +use vectorcraft_engine::{EngineError, READ_ONLY_LOCATION, Session}; + +use super::{fileio, save}; +use crate::control::ControlRequest; +use crate::tests::picks::off_the_ui_thread; +use crate::{Services, VectorcraftApp, background, dialogs, picks}; + +const DOCUMENTS: &str = "/home/artist/Documents"; + +#[derive(Default)] +struct Files { + original: String, + attempts: Vec, + kind: Option, + fail_second: bool, +} + +fn write(files: &Mutex, path: &str) -> vectorcraft_engine::Result<()> { + let mut files = files.lock().unwrap(); + files.attempts.push(path.into()); + if path == files.original || files.fail_second { + let reason = format!("{path}: injected refusal"); + return Err(match files.kind { + Some(ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) => EngineError::ReadOnlyLocation(reason), + _ => EngineError::Other(reason), + }); + } + Ok(()) +} + +fn app(format: &str, kind: ErrorKind) -> (VectorcraftApp, Arc>) { + let files = Arc::new(Mutex::new(Files { original: format!("/locked/Poster.{format}"), kind: Some(kind), ..Default::default() })); + let typed = files.clone(); + let ordinary = files.clone(); + let services = Services { + save_write: Some(Arc::new(move |path, _| write(&typed, path))), + write: Some(Box::new(move |path, _| { + write(&ordinary, path).map_err(|e| match e { + EngineError::ReadOnlyLocation(reason) => reason, + e => e.to_string(), + }) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 40, "height": 40})).unwrap(); + app.run("shape.rectangle", json!({"x": 1, "y": 1, "width": 10, "height": 10})).unwrap(); + let st = app.session.doc_mut().unwrap(); + st.path = Some(files.lock().unwrap().original.clone()); + st.format = fileio::format(format).unwrap().id; + st.save_options = if format == "svg" { + json!({"decimals": 2, "preserveEditing": false}) + } else { + json!({"pdfCompatible": false, "compress": true, "preview": false}) + } + .as_object() + .unwrap() + .clone(); + (app, files) +} + +fn state(app: &VectorcraftApp) -> (Option, bool, &'static str, Value) { + let st = app.session.doc().unwrap(); + (st.path.clone(), st.is_dirty(), st.format, json!(st.save_options)) +} + +fn poll(app: &mut VectorcraftApp) { + picks::poll(app, &egui::Context::default()); +} + +fn normal_error(app: &VectorcraftApp) { + let d = app.ui.dialog.as_ref().expect("a persistent error dialog"); + assert_eq!(d.kind, dialogs::confirm::MESSAGE); + assert!(d.str("detail").contains("injected refusal")); + assert!(!d.str("detail").contains(READ_ONLY_LOCATION)); + assert!(app.ui.told.is_empty(), "no duplicate error dialog"); +} + +#[test] +fn readonly_fallback_prefills_save_as_and_cancel_keeps_the_failed_save_state() { + for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem] { + let (mut app, files) = app("svg", kind); + let before = state(&app); + let (shown, _) = off_the_ui_thread(&mut app.services); + let r = save::save_current_in(&mut app, &json!({"modified": null}), Some(DOCUMENTS.into())).unwrap(); + assert_eq!(r["pending"], "fileDialog"); + let requests = shown.requests(); + let [picks::PickRequest::Save(pick)] = requests.as_slice() else { panic!("expected one save panel: {requests:?}") }; + assert_eq!(pick.name, "Poster.svg"); + assert_eq!(pick.folder.as_deref(), Some(DOCUMENTS)); + assert_eq!(pick.filters.first(), crate::FilePick::filters(fileio::save_filters("svg")).first()); + assert!(app.ui.status.contains("choose where to save it")); + assert_eq!(state(&app), before); + assert!(app.ui.dialog.is_none()); + shown.answer(&[]); + poll(&mut app); + assert_eq!(state(&app), before); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg"]); + assert!(!app.picks.is_waiting()); + } +} + +#[test] +fn readonly_save_as_retargets_only_after_write_and_keeps_options() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + let before = state(&app); + let uid = app.session.doc().unwrap().uid; + let (shown, _) = off_the_ui_thread(&mut app.services); + assert_eq!(app.run("file.save", json!({})).unwrap()["pending"], "fileDialog"); + // Hold the second write after the picker answers: the chosen path is not yet the document's. + let (entered, writing) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let released = Mutex::new(released); + let f = files.clone(); + app.services.save_write = Some(Arc::new(move |path, _| { + entered.send(()).unwrap(); + released.lock().unwrap().recv().unwrap(); + write(&f, path) + })); + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed saves must use their own writer"))); + app.session.prefs.background_save = true; + shown.answer(&["/home/artist/Documents/Poster.svg"]); + poll(&mut app); + writing.recv().unwrap(); + assert_eq!(state(&app), before); + release.send(()).unwrap(); + background::wait_all(&mut app); + let st = app.session.document_mut(uid).unwrap(); + assert_eq!(st.path.as_deref(), Some("/home/artist/Documents/Poster.svg")); + assert!(!st.is_dirty()); + assert_eq!(st.format, before.2); + assert_eq!(json!(st.save_options), before.3); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg", "/home/artist/Documents/Poster.svg"]); + assert!(shown.requests().is_empty() && app.ui.dialog.is_none()); +} + +#[test] +fn readonly_second_failure_shows_one_dialog_without_reopening_the_picker() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + files.lock().unwrap().fail_second = true; + let before = state(&app); + let (shown, _) = off_the_ui_thread(&mut app.services); + app.run("file.save", json!({})).unwrap(); + shown.answer(&["/elsewhere/Poster.svg"]); + poll(&mut app); + normal_error(&app); + assert_eq!(state(&app), before); + assert!(shown.requests().is_empty() && !app.picks.is_waiting()); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg", "/elsewhere/Poster.svg"]); +} + +#[test] +fn readonly_other_errors_and_explicit_destinations_keep_the_dialog() { + for (command, params, kind) in [ + ("file.save", json!({}), ErrorKind::StorageFull), + ("file.save", json!({}), ErrorKind::NotFound), + ("file.save", json!({}), ErrorKind::Other), + ("file.save", json!({"path": "/locked/Poster.svg"}), ErrorKind::PermissionDenied), + ("file.saveAs", json!({"path": "/locked/Poster.svg", "options": {"decimals": 2}}), ErrorKind::PermissionDenied), + ("file.saveCopy", json!({"path": "/locked/Poster.svg", "options": {"decimals": 2}}), ErrorKind::PermissionDenied), + ("file.exportAs", json!({"path": "/locked/Poster.svg", "format": "svg"}), ErrorKind::PermissionDenied), + ] { + let (mut app, _) = app("svg", kind); + let before = state(&app); + // A fallback would return pending, so no inline/native dialog is necessary here. + app.services.pick_save = Some(Box::new(move |_| panic!("{command}: no fallback"))); + assert!(app.run(command, params).is_err(), "{command}"); + normal_error(&app); + assert_eq!(state(&app), before); + } +} + +#[test] +fn readonly_control_returns_the_prefix_and_never_opens_a_panel_even_after_completion() { + for wait in [true, false] { + let (mut app, _) = app("svg", ErrorKind::PermissionDenied); + app.services.pick_save = Some(Box::new(|_| panic!("automation never picks"))); + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed writer required"))); + app.session.prefs.background_save = true; + let before = state(&app); + let (tx, rx) = mpsc::channel(); + app = app.with_control(rx); + let (req, reply) = ControlRequest::new("app.save", json!({"wait": wait})); + tx.send(req).unwrap(); + app.drain_control(&egui::Context::default()); + let reply = reply.try_recv().unwrap(); + if wait { + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap().starts_with("READ_ONLY_LOCATION: "), "{reply}"); + } else { + assert_eq!(reply["result"]["background"], true); + assert!(!app.ui.answering_control); + background::wait_all(&mut app); + assert!(app.ui.status.contains("READ_ONLY_LOCATION: ")); + } + assert_eq!(state(&app), before); + assert!(app.ui.dialog.is_none() && app.ui.told.is_empty() && !app.picks.is_waiting()); + } +} + +#[test] +fn readonly_automation_continuation_keeps_the_prefix_and_never_opens_a_panel() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + let before = state(&app); + app.services.pick_save = Some(Box::new(|_| panic!("automation never picks"))); + assert!(!app.automation_is_acting()); + let r = picks::as_automated(&mut app, true, |app| app.run("file.save", json!({}))); + assert!(r.unwrap_err().starts_with("READ_ONLY_LOCATION: ")); + assert_eq!(state(&app), before); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg"]); + assert!(app.ui.dialog.is_none() && app.ui.told.is_empty() && !app.picks.is_waiting()); + assert!(!app.automation_asked()); +} + +#[test] +fn readonly_background_completion_resumes_the_original_uid_without_repeating_save() { + for streamed in [false, true] { + let format = if streamed { "vectorcraft" } else { "svg" }; + let (mut app, files) = app(format, ErrorKind::ReadOnlyFilesystem); + let original = files.lock().unwrap().original.clone(); + let uid = app.session.doc().unwrap().uid; + let (shown, _) = off_the_ui_thread(&mut app.services); + if streamed { + let f = files.clone(); + app.services.save_stream = Some(Arc::new(move |path, fill| { + let mut bytes = Vec::new(); + fill(&mut bytes).unwrap(); + write(&f, path) + })); + app.services.save_write = Some(Arc::new(|_, _| panic!("native save must stream"))); + } + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed writer required"))); + app.session.prefs.background_save = true; + assert_eq!(app.run("file.save", json!({})).unwrap()["background"], true); + app.run("file.new", json!({"width": 20, "height": 20})).unwrap(); + let other = app.session.doc().unwrap().uid; + background::wait_all(&mut app); + assert_eq!(app.session.doc().unwrap().uid, uid); + assert_eq!(shown.requests().len(), 1); + // Switch again while the native panel is open: its answer belongs to the original uid. + app.run("document.activate", json!({"index": 1})).unwrap(); + let chosen = format!("/elsewhere/Poster.{format}"); + shown.answer(&[&chosen]); + poll(&mut app); + background::wait_all(&mut app); + assert_eq!(app.session.doc().unwrap().uid, uid); + assert_eq!(app.session.doc().unwrap().path.as_deref(), Some(chosen.as_str())); + assert!(!app.session.doc().unwrap().is_dirty()); + assert!(app.session.document_mut(other).unwrap().path.is_none()); + assert_eq!(files.lock().unwrap().attempts, [original, chosen]); + assert!(shown.requests().is_empty()); + } +} + +#[test] +fn readonly_busy_picker_and_save_changes_keep_the_existing_error_flow() { + let (mut app, _) = app("svg", ErrorKind::PermissionDenied); + let (shown, _) = off_the_ui_thread(&mut app.services); + app.run("file.saveAs", json!({})).unwrap_err(); + assert_eq!(shown.requests().len(), 1); + assert!(app.run("file.save", json!({})).is_err()); + normal_error(&app); + assert_eq!(shown.requests().len(), 1); + shown.answer(&[]); + poll(&mut app); + app.ui.dialog = None; + let st = app.session.doc().unwrap(); + app.ui.dialog = Some(crate::state::Dialog::new(crate::unsaved::KIND, json!({"index": 0, "uid": st.uid, "then": "close"}))); + assert!(crate::unsaved::confirm(&mut app).is_err()); + normal_error(&app); + assert!(shown.requests().is_empty()); + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.doc().unwrap().is_dirty()); +} + +#[test] +fn readonly_changed_format_asks_for_its_options_before_writing() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + let before = state(&app); + let (shown, _) = off_the_ui_thread(&mut app.services); + assert_eq!(app.run("file.save", json!({})).unwrap()["pending"], "fileDialog"); + shown.answer(&["/elsewhere/Poster.pdf"]); + poll(&mut app); + let dialog = app.ui.dialog.as_ref().expect("the picked format's Save dialog"); + assert_eq!(dialog.kind, "savePdf"); + assert_eq!(dialog.str("path"), "/elsewhere/Poster.pdf"); + assert_eq!(state(&app), before); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg"]); +} + +/// Typed saves keep the sidecar preflight both before the refused write and at the retry's +/// destination. Confirming the ordinary Save still permits its one read-only fallback. +#[test] +fn readonly_typed_save_checks_sidecars_before_writing_and_after_retry() { + for in_background in [false, true] { + let (mut app, files) = app("vectorcraft", ErrorKind::PermissionDenied); + app.run("artboard.new", json!({"x": 100, "y": 0, "width": 40, "height": 40, "name": "B"})).unwrap(); + app.session.doc_mut().unwrap().save_options.insert("separateArtboards".into(), json!(true)); + let before = state(&app); + let there = ["/locked/Poster-B.vectorcraft", "/elsewhere/Poster-B.vectorcraft"]; + app.services.exists = Some(Arc::new(move |path| there.contains(&path))); + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed writer required"))); + app.session.prefs.background_save = in_background; + let (shown, _) = off_the_ui_thread(&mut app.services); + + app.run("file.save", json!({})).unwrap(); + background::wait_all(&mut app); + let d = app.ui.dialog.as_ref().expect("ask before writing the existing artboard file"); + assert_eq!(d.kind, dialogs::confirm::REPLACE_FILES); + assert_eq!(d.fields["files"], json!(["/locked/Poster-B.vectorcraft"])); + assert!(files.lock().unwrap().attempts.is_empty()); + assert!(shown.requests().is_empty()); + assert_eq!(state(&app), before); + dialogs::cancel(&mut app); + assert_eq!(state(&app), before); + assert!(files.lock().unwrap().attempts.is_empty()); + + app.run("file.save", json!({})).unwrap(); + background::wait_all(&mut app); + dialogs::confirm(&mut app).unwrap(); + background::wait_all(&mut app); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.vectorcraft"]); + assert_eq!(state(&app), before); + let requests = shown.requests(); + let [picks::PickRequest::Save(pick)] = requests.as_slice() else { panic!("expected one fallback panel: {requests:?}") }; + assert_eq!(pick.name, "Poster.vectorcraft"); + + shown.answer(&["/elsewhere/Poster.vectorcraft"]); + poll(&mut app); + background::wait_all(&mut app); + let d = app.ui.dialog.as_ref().expect("the retry also checks its destination's artboard file"); + assert_eq!(d.kind, dialogs::confirm::REPLACE_FILES); + assert_eq!(d.fields["files"], json!(["/elsewhere/Poster-B.vectorcraft"])); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.vectorcraft"]); + assert_eq!(state(&app), before); + dialogs::confirm(&mut app).unwrap(); + background::wait_all(&mut app); + assert_eq!( + files.lock().unwrap().attempts, + [ + "/locked/Poster.vectorcraft", + "/elsewhere/Poster.vectorcraft", + "/elsewhere/Poster-Artboard-1.vectorcraft", + "/elsewhere/Poster-B.vectorcraft" + ] + ); + assert_eq!(app.session.doc().unwrap().path.as_deref(), Some("/elsewhere/Poster.vectorcraft")); + assert!(!app.session.doc().unwrap().is_dirty()); + assert!(shown.requests().is_empty() && app.ui.dialog.is_none()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index be668e1e4f..b5964fe274 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -122,8 +122,8 @@ pub use craft_ui::docking::Monitor as DockMonitor; #[cfg(test)] pub(crate) use services::browsable_url; pub use services::{ - DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, PickedInbox, ReadFn, RevealFn, Services, - WriteFn, + DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, PickedInbox, ReadFn, RevealFn, SaveStreamFn, + SaveWriteFn, Services, WriteFn, }; use state::{GeometryStamp, InputGeneration, PendingScreenshot, QueuedScreenshot}; pub(crate) use state::{InputFrame, take_frame_once}; @@ -197,6 +197,10 @@ pub struct VectorcraftApp { frame_rate: frame_rate::FrameRate, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. pub canvas_rect: Option, + /// The UI zoom (egui's zoom factor, set by Preferences › User Interface › UI Scaling) of the + /// frame that laid out [`Self::canvas_rect`]; 1.0 before the first one. [`Self::canvas_xf`] + /// maps the document to that rect with it. + pub(crate) canvas_ui_zoom: f32, /// An unfinished control-channel Rotate View gesture, kept only for this app instance. pub(crate) control_rotate_press: Option, /// Hover position in document coordinates. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1698e06ed4..26dd765d29 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -71,10 +71,8 @@ pub(crate) use invoke::graph_dialog; #[cfg(test)] pub(crate) use invoke::with_held_keys; pub use invoke::{click_target, invoke, invoke_from_system_menu}; -#[cfg(test)] pub(crate) use labels::lists_plugin; -#[cfg(test)] -use labels::shows_a_name; +pub(crate) use labels::shows_a_name; pub use labels::{CONTEXT_LABELS, DYNAMIC_LABELS, display_label, dynamic_label, item_shortcut, menu_strings}; pub use run::{check_params, run_ui_command}; pub(crate) use run::{normalize_panel, opt_bool, opt_index, opt_number}; diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index 408e5f077a..b8829ac0a2 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -125,7 +125,7 @@ fn command_label(app: &VectorcraftApp, id: &str, label: &str) -> Option /// label: a recent file, a saved view, a font, a user library or preset, a custom workspace, a /// plug-in, an SVG filter, a script of the Scripts folder? Such names are shown as they are, never /// translated (a workspace the user calls "Layers" stays "Layers"). -pub(super) fn shows_a_name(id: &str, label: &str) -> bool { +pub(crate) fn shows_a_name(id: &str, label: &str) -> bool { const SLOTS: [&str; 8] = [ "file.openRecent", "view.goto", diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index de3261de1b..72e6c25acb 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -165,7 +165,8 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option SaveMode::Copy, _ => SaveMode::Template, }; - io::save(app, mode, p, true).map(|mut r| { + let saved = if mode == SaveMode::Save { io::save_current(app, p) } else { io::save(app, mode, p, true) }; + saved.map(|mut r| { vectorcraft_engine::cmd::fileio::leave_out_unreadable_save_paths(&mut r, p.get("path").and_then(Value::as_str)); r }) diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index d68608824b..4deb57ecf9 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -41,9 +41,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { crate::panels::character::shared_text_flag(app, |t| t.vertical) == Some(id == "type.orientation.vertical") } "type.opticalMarginAlignment" => crate::panels::character::shared_text_flag(app, |t| t.optical_margin) == Some(true), - // Dynamic Text of the first selected type on a path or area type, which the item turns the - // other way. - "type.dynamicText" => selected_text(|k| !matches!(k, vectorcraft_engine::doc::TextKind::Point)).is_some_and(|t| t.dynamic_text), + // Dynamic Text of the first selected type, which the item turns the other way (point type + // counts as off: the item turns it into area type with Block on). + "type.dynamicText" => selected_text(|_| true).is_some_and(|t| t.dynamic_text && !matches!(t.kind, vectorcraft_engine::doc::TextKind::Point)), "view.outline" => app.outline_view(), "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index e49f4788ea..d8ca0b79ec 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -313,8 +313,8 @@ fn optical_margin_alignment_is_a_checked_type_menu_item() { assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false)); } -/// Type › Dynamic Text runs `type.dynamicText`, is enabled for type on a path and area type that -/// are in no thread, and is checked while the selected type has it on. +/// Type › Dynamic Text runs `type.dynamicText`, is enabled for point type and for type on a path +/// and area type that are in no thread, and is checked while the selected type has it on. #[test] fn dynamic_text_is_a_checked_type_menu_item() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); @@ -334,7 +334,8 @@ fn dynamic_text_is_a_checked_type_menu_item() { assert!(!enabled(&app, id)); let point = app.run("text.create", json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].clone(); app.run("select.set", json!({"ids": [point]})).unwrap(); - assert!(!enabled(&app, id), "point type"); + assert!(enabled(&app, id), "point type, which it turns into area type with Block on"); + assert_eq!(checked(&app, id, &Value::Null), Some(false), "point type"); let path = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone(); let t = app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone(); app.run("select.set", json!({"ids": [t]})).unwrap(); @@ -356,6 +357,63 @@ fn dynamic_text_is_a_checked_type_menu_item() { assert_eq!(checked(&app, id, &Value::Null), Some(false)); app.run(id, json!({})).unwrap(); assert_eq!(checked(&app, id, &Value::Null), Some(true)); + // Point type first: unchecked, as the item turns it on. + app.run("select.set", json!({"ids": [point, area]})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(false), "point type first"); + app.run(id, json!({})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(true), "both on"); +} + +/// Type › Type on a Path › Center on Top and Center on Bottom run `type.pathCenter.top` and +/// `.bottom`, which the command palette also runs without params, and are enabled for type on a +/// closed path only. +#[test] +fn center_on_top_and_bottom_are_type_on_a_path_items() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let items: Vec<(&str, &str)> = menu_tree() + .into_iter() + .filter(|(menu, _)| *menu == "Type") + .flat_map(|(_, items)| items) + .filter_map(|item| match item { + Item::Sub("Type on a Path", children) => Some(children), + _ => None, + }) + .flatten() + .filter_map(|item| match item { + Item::Cmd(label, id, Value::Null) if id.starts_with("type.pathCenter") => Some((label, id)), + _ => None, + }) + .collect(); + let ids = ["type.pathCenter.top", "type.pathCenter.bottom"]; + assert_eq!(items, [("Center on Top", ids[0]), ("Center on Bottom", ids[1])]); + let palette = crate::palette::items(); + for (label, id) in items { + assert!(palette.iter().any(|(l, i, _)| l == &format!("Type › Type on a Path › {label}") && i == id), "{label}"); + } + let line = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone(); + let open = app.run("text.createInPath", json!({"path": line, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [open]})).unwrap(); + assert!(ids.iter().all(|id| !enabled(&app, id)), "type on an open path"); + let square = app.run("path.create", json!({"d": "M60 200 L160 200 L160 300 L60 300 Z"})).unwrap()["id"].clone(); + let closed = app.run("text.createInPath", json!({"path": square, "mode": "onPath", "text": "Round", "at": [60, 200]})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [closed]})).unwrap(); + assert!(ids.iter().all(|id| enabled(&app, id))); + app.run("select.set", json!({"ids": [closed, open]})).unwrap(); + assert!(ids.iter().all(|id| !enabled(&app, id)), "type on a closed path and on an open one"); + // A palette pick runs the command with no params. + app.run("select.set", json!({"ids": [closed]})).unwrap(); + crate::palette::run_pick(&mut app, ids[0]); + let doc = &app.session.active().unwrap().doc; + let start = match &doc.node(vectorcraft_doc::NodeId(closed.as_u64().unwrap())).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => match t.kind { + vectorcraft_doc::TextKind::OnPath { start, .. } => start, + ref k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + }; + // Once round, the middle of the top edge an eighth of the way round. + assert!((start - 0.625).abs() < 1e-6, "{start}"); } /// Type › Dynamic Text Presets › Circle, Arch and Bow run `type.dynamicTextPreset` with their diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index fb12428354..5b88346f82 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -396,7 +396,13 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { PATH_EFFECTS .iter() .map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect }))) - .chain([Sep, c("Type on a Path Options…", "type.pathOptions")]) + .chain([ + Sep, + c("Center on Top", "type.pathCenter.top"), + c("Center on Bottom", "type.pathCenter.bottom"), + Sep, + c("Type on a Path Options…", "type.pathOptions"), + ]) .collect(), ), c("Dynamic Text", "type.dynamicText"), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f435bfff22..e65ea938a6 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -40,7 +40,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.save", "Save", "Cmd+S", - "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first; ordinary Save to the document's existing file refused by filesystem permissions or a read-only volume opens Save As in Documents with the same name, format and options; cancelling keeps the original path and modified state. Explicit destinations and Close/Quit keep their existing error dialog. Automation gets READ_ONLY_LOCATION: followed by the reason and no panel", ), ( "file.place", @@ -405,7 +405,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]}"), ( @@ -802,7 +802,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", diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index d5bf7b6098..7b82af2e53 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -91,7 +91,10 @@ fn build(lang: crate::i18n::Lang) -> Vec { } /// Runs the palette result `id`: selects a tool, or invokes a command with the params its entry -/// stands for. +/// stands for. A command whose menu bar item gives params runs what a click on that item runs +/// ([`crate::shortcut_editor::menu_target_now`]), so Clean Up… and Find and Replace… open their +/// dialogs. While that item is dimmed, no dialog opens and the status line says why the command +/// can't run. pub(crate) fn run_pick(app: &mut VectorcraftApp, id: &str) { if let Some(tool) = id.strip_prefix("tool:") { app.select_tool(tool); @@ -99,7 +102,11 @@ pub(crate) fn run_pick(app: &mut VectorcraftApp, id: &str) { let command = crate::keymap::command_of(id); menus::invoke(app, &command.id, command.params); } else { - menus::invoke(app, id, json!({})); + match crate::shortcut_editor::menu_target_now(app, id) { + Some(Ok((id, p))) => menus::invoke(app, &id, p), + Some(Err(why)) => app.status(why), + None => menus::invoke(app, id, json!({})), + } } } @@ -217,7 +224,7 @@ mod tests { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); let before = app.session.prefs.use_precise_cursors; let first = matches.first().expect("query has a result"); - menus::invoke(&mut app, &first.id, json!({})); + run_pick(&mut app, &first.id); assert_ne!(app.session.prefs.use_precise_cursors, before, "first result toggles the preference"); } @@ -238,8 +245,7 @@ mod tests { app.run("select.set", json!({"ids": [a, b]})).unwrap(); app.run("select.key", json!({"id": b})).unwrap(); assert!(app.session.active().unwrap().selection.key.is_some()); - // Run a pick the way `show` runs it. - menus::invoke(&mut app, ids[0], json!({})); + run_pick(&mut app, ids[0]); let st = app.session.active().unwrap(); assert_eq!((st.selection.len(), st.selection.key), (2, None), "both objects stay selected, without a key"); } @@ -255,20 +261,163 @@ mod tests { app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); app.ui.status.clear(); assert!(!crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}"); - // Run a pick the way `show` runs it. - menus::invoke(&mut app, id, json!({})); + run_pick(&mut app, id); assert!(crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}: {}", app.ui.status); assert!(!app.ui.status.contains("invalid parameters"), "{id}: {}", app.ui.status); // A second pick leaves the open panel open, docked or floating. - menus::invoke(&mut app, id, json!({})); + run_pick(&mut app, id); assert!(crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}: picked twice"); app.run("window.panel.float", json!({"panel": crate::panels::asset_export::ID})).unwrap(); assert!(crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}: floating"); - menus::invoke(&mut app, id, json!({})); + run_pick(&mut app, id); assert!(crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}: floating, picked again"); } } + /// The open dialog's kind and fields, the active document's undo depth and the status line. + type Outcome = (Option<(String, serde_json::Map)>, Option, String); + + /// The [`Outcome`] `app` shows. + fn outcome(app: &VectorcraftApp) -> Outcome { + ( + app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.clone())), + app.session.active().map(|st| st.history.undo.len()), + app.ui.status.clone(), + ) + } + + /// A pick of a "…" command whose menu bar item gives params opens that item's dialog, and Smooth + /// opens its slider; nothing changes until the dialog is confirmed. + #[test] + fn a_pick_opens_the_dialog_its_menu_item_opens() { + let expected = [ + ("object.path.cleanUp", "command"), + ("object.mesh.create", "command"), + ("type.smartPunctuation", "command"), + ("edit.findReplace", "command"), + ("object.path.smooth", crate::dialogs::smooth_slider::KIND), + ]; + for (id, kind) in expected { + assert!(items().iter().any(|(_, item, _)| item == id), "{id} is in the palette"); + let mut app = VectorcraftApp::new(vectorcraft_testkit::fixtures::Fixture::Single.session(), crate::Services::default()); + app.ui.status.clear(); + let before = app.session.active().unwrap().history.undo.len(); + run_pick(&mut app, id); + let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("{id} opens its dialog: {}", app.ui.status)); + assert_eq!(d.kind, kind, "{id}"); + if kind == "command" { + assert_eq!(d.fields["__command"], json!(id), "{id}: {:?}", d.fields); + } + assert_eq!(app.session.active().unwrap().history.undo.len(), before, "{id} changes nothing yet"); + assert!(app.ui.status.is_empty(), "{id}: {}", app.ui.status); + } + } + + /// Every palette command whose menu bar items all give it the same params does what a click on + /// such an item does, as the menu row shows it ([`menus::entry`]): with no document open, in an + /// empty document and with one or several objects selected, the pick opens the same dialog with + /// the same fields and leaves the same undo steps and status. While the row is dimmed, the pick + /// opens no dialog, changes nothing and says why the command can't run. + #[test] + fn every_pick_runs_what_its_menu_item_runs() { + use vectorcraft_testkit::fixtures::Fixture; + fn walk<'a>(items: &'a [menus::Item], out: &mut Vec<&'a menus::Item>) { + for it in items { + match it { + menus::Item::Sub(_, children) => walk(children, out), + // Items that list an installed plug-in or show a name that is user data come and + // go as tests beside this one install plug-ins or add toolbars, and no shortcut or + // pick reads them. + menus::Item::Cmd(label, id, p) if !menus::lists_plugin(id, p) && !menus::shows_a_name(id, label) => out.push(it), + _ => {} + } + } + } + let tree = menus::menu_tree(); + let mut rows = vec![]; + for (_, items) in &tree { + walk(items, &mut rows); + } + let mut checked: Vec<(String, &menus::Item)> = vec![]; + for (_, id, _) in items() { + let of_id: Vec<&menus::Item> = rows.iter().copied().filter(|it| matches!(it, menus::Item::Cmd(_, c, _) if *c == id)).collect(); + let params: Vec<&serde_json::Value> = + of_id.iter().filter_map(|it| if let menus::Item::Cmd(_, _, p) = it { Some(p) } else { None }).collect(); + let Some(first) = params.first() else { continue }; + if first.as_object().is_none_or(serde_json::Map::is_empty) || params.iter().any(|p| p != first) { + continue; + } + checked.push((id, of_id[0])); + } + assert!(checked.iter().any(|(id, _)| id == "edit.findReplace"), "{:?}", checked.iter().map(|c| &c.0).collect::>()); + let state = |n: usize| match n.checked_sub(1) { + None => vectorcraft_engine::Session::new(), + Some(k) => Fixture::ALL[k].session(), + }; + let (mut compared, mut dimmed) = (0, 0); + for (id, item) in &checked { + for n in 0..=Fixture::ALL.len() { + let mut picked = VectorcraftApp::new(state(n), crate::Services::default()); + let mut clicked = VectorcraftApp::new(state(n), crate::Services::default()); + picked.ui.status.clear(); + clicked.ui.status.clear(); + // A row the menu leaves out in this state has no click to compare with. + let Some(menus::Entry::Item(row)) = menus::entry(&clicked, item) else { continue }; + let (target, enabled) = (row.target().expect("the item is implemented"), row.enabled); + let before = outcome(&picked); + run_pick(&mut picked, id); + if enabled { + menus::invoke(&mut clicked, &target.0, target.1); + assert_eq!(outcome(&picked), outcome(&clicked), "{id} [state {n}]"); + compared += 1; + } else { + let after = outcome(&picked); + assert_eq!((after.0, after.1), (None, before.1), "{id} [state {n}] is dimmed"); + assert!(after.2.contains("not available"), "{id} [state {n}]: {}", after.2); + dimmed += 1; + } + } + } + assert!(compared > 0 && dimmed > 0, "{compared} compared, {dimmed} dimmed"); + } + + /// A pick of a "…" command whose menu bar item is dimmed (Remove Background and Object Mosaic + /// with no image selected, Find and Replace with no document open) opens no dialog, changes + /// nothing and reports why the command can't run. + #[test] + fn a_pick_of_a_dimmed_command_says_why_it_cannot_run() { + let cases = [ + ("addon.image.removeBackground", vectorcraft_testkit::fixtures::Fixture::Multi.session()), + ("object.createObjectMosaic", vectorcraft_testkit::fixtures::Fixture::Multi.session()), + ("edit.findReplace", vectorcraft_engine::Session::new()), + ]; + for (id, session) in cases { + assert!(crate::shortcut_editor::menu_target(id).is_some(), "{id} has a dialog"); + let mut app = VectorcraftApp::new(session, crate::Services::default()); + assert!(!menus::enabled(&app, id), "{id}"); + app.ui.status.clear(); + let before = app.session.active().map(|st| st.history.undo.len()); + run_pick(&mut app, id); + assert!(app.ui.dialog.is_none(), "{id}"); + assert_eq!(app.session.active().map(|st| st.history.undo.len()), before, "{id}"); + assert!(app.ui.status.contains("not available"), "{id}: {}", app.ui.status); + } + } + + /// A pick of a command that opens no dialog runs it at once. + #[test] + fn a_pick_of_a_command_without_a_dialog_runs_at_once() { + for id in ["object.group", "paint.swap"] { + assert!(items().iter().any(|(_, item, _)| item == id), "{id} is in the palette"); + assert_eq!(crate::shortcut_editor::menu_target(id), None, "{id}"); + let mut app = VectorcraftApp::new(vectorcraft_testkit::fixtures::Fixture::Multi.session(), crate::Services::default()); + let before = app.session.active().unwrap().history.undo.len(); + run_pick(&mut app, id); + assert!(app.ui.dialog.is_none(), "{id}"); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1, "{id}: {}", app.ui.status); + } + } + /// #1757: Up and Down move the highlighted result, wrapping at the ends; a new query starts /// from the first again. #[test] 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/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index f28e7e5540..10acd4603e 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -155,9 +155,10 @@ fn find_filters_list_view_and_arrows_step_through_the_libraries() { assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none()); let first = SWATCH_IDS[0]; let last = SwatchLibraries::list(&app).pop().unwrap().id; - assert_eq!(last, "grit-textures", "gradient and then pattern libraries come after the swatch libraries"); + assert_eq!(last, "optical-effects", "gradient and then pattern libraries come after the swatch libraries"); assert_eq!(neighbour::(&app, "radial-glows", 1).as_deref(), Some("geometric-patterns")); assert_eq!(neighbour::(&app, "geometric-patterns", 1).as_deref(), Some("grit-textures")); + assert_eq!(neighbour::(&app, "grit-textures", 1).as_deref(), Some("optical-effects")); assert_eq!(neighbour::(&app, first, -1), Some(last), "wraps around"); assert_eq!(neighbour::(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1])); } 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/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 6a984a1ced..c7b8e47828 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -346,7 +346,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 +358,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/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 4d8959f262..157b58d3cb 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -136,9 +136,12 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { json!("pattern-borders"), json!("scatter-shapes"), json!("game-trails"), - json!("energy-beams") + json!("energy-beams"), + json!("dry-media") ] ); + let dry = entries.iter().find(|e| under(e) && e.params["library"] == "dry-media").map(|e| e.label.as_str()); + assert_eq!(dry, Some("Dry Media")); let enabled = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(enabled("window.brushLibrary.other"), Some(true)); assert_eq!(enabled("ui.saveBrushLibrary"), Some(true), "a new document has the default brushes"); @@ -152,6 +155,13 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { for t in ["Open Brush Library", "Save Brush Library…"] { assert!(texts.iter().any(|x| x.ends_with(t)), "{t}: {texts:?}"); } + // Dry Media opens in a library panel, and a click on Chalk Grain adds it in one step. + let r = app.run("window.brushLibrary", json!({"library": "Dry Media"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("dry-media"), Some(4))); + let undo = undo_len(&app); + click(&mut app, &ctx, "Chalk Grain", Modifiers::NONE, 1.0); + assert!(has(&app, "Chalk Grain") && !has(&app, "Wax Crayon")); + assert_eq!(undo_len(&app), undo + 1); // Without brushes there is nothing to save. let all: Vec = vectorcraft_brush::library(&app.session.doc().unwrap().doc).into_iter().map(|b| b.name).collect(); app.run("brush.delete", json!({ "names": all })).unwrap(); diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index d2b0a26d3f..ea1b51e049 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -223,6 +223,15 @@ pub(crate) fn as_entry(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entr r } +/// Run a continuation under its own entry: a refused Save's panel must resume Save As, not retry +/// the original Save. Restore the enclosing entry for any work it does afterwards. +pub(crate) fn as_own_entry(app: &mut VectorcraftApp, entry: Entry, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R { + let outer = app.picks.entry.replace(entry); + let r = f(app); + app.picks.entry = outer; + r +} + /// Run `f` as handling the answer of a file dialog automation opened when `automated` (and inside /// one already, as that): [`VectorcraftApp::automation_asked`] is true meanwhile. pub(crate) fn as_automated(app: &mut VectorcraftApp, automated: bool, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R { 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/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 3e74ad394f..676a9a48c8 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -43,6 +43,13 @@ pub type ReadHeadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; +/// A save writer that keeps typed filesystem failures, on either the UI or background thread. +pub type SaveWriteFn = Arc vectorcraft_engine::Result<()> + Send + Sync>; + +/// A typed save writer for native files encoded directly into the output. +pub type SaveStreamFn = + Arc std::io::Result<()>) -> vectorcraft_engine::Result<()> + Send + Sync>; + /// Shared, so Background Save and Export can check from a worker thread too. pub type ExistsFn = Arc bool + Send + Sync>; @@ -140,6 +147,10 @@ pub struct Services { /// stream into the file as they are encoded instead of being held whole in memory first. /// Without it they are encoded, then written with `write` / `write_shared`. pub stream_shared: Option, + /// Typed writers for document saves only. Without them the existing string writers are used + /// and failures keep their normal dialog. Downloads never use these callbacks. + pub save_write: Option, + pub save_stream: Option, /// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show /// Package). Without it (the web) those answer with an error. pub open_file: Option, @@ -149,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..e7c64463de 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -449,17 +449,17 @@ fn fixed_entries() -> &'static (Vec, Vec) { .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true }) .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 }); } @@ -472,13 +472,36 @@ fn fixed_entries() -> &'static (Vec, Vec) { // 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; } + 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 }); } + // 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. + 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) }) } @@ -653,23 +676,31 @@ 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 !menus::lists_plugin(id, p) && !menus::shows_a_name(id, label) => { + 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 } fn is_bare(p: &Value) -> bool { @@ -686,6 +717,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 +744,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 +758,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. @@ -2270,6 +2339,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 +2420,70 @@ 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}"); + } + } + /// `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..f0ff34ef64 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -985,9 +985,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 +1031,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..e002d07adf 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() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4a0e73da6b..ec026573d0 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 diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 26406a4992..3a84e0b94d 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -567,6 +567,50 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect`'s `scriptReport` names a file the person's script wrote outside the read root, and +/// the included file a failed run stopped in (also in its error line), by its file name; the person +/// sees the paths. +#[test] +fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-script-report"); + let (kept, hidden) = (s(base.join("in/kept.svg")), s(base.join("outside/hidden.svg"))); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let source = format!( + "vc.run('document.export', {{path: {kept}, format: 'svg'}}); vc.run('document.export', {{path: {hidden}, format: 'svg'}});", + kept = json!(kept), + hidden = json!(hidden) + ); + let finish = |app: &mut VectorcraftApp, params: Value| { + app.session.execute("script.run", ¶ms).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + }; + finish(&mut app, json!({"source": source, "name": "t.js"})); + let ctx = egui::Context::default(); + let outside = json!(s(base.join("outside"))).to_string(); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["scriptReport"]["written"], json!([kept, hidden]), "{person}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["written"], json!([kept, "hidden.svg"]), "{r}"); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + assert_eq!(app.session.script_report().unwrap()["written"], json!([kept, hidden]), "the report is not changed"); + + // A run that fails in a file it includes. + std::fs::write(base.join("outside/run.jsx"), "#include \"bad.jsxinc\"\n").unwrap(); + std::fs::write(base.join("outside/bad.jsxinc"), "\nthrow new Error('stopped here');\n").unwrap(); + finish(&mut app, json!({"path": s(base.join("outside/run.jsx"))})); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["scriptReport"]["file"], json!(s(base.join("outside/bad.jsxinc"))), "{person}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["file"], "bad.jsxinc", "{r}"); + assert!(r["result"]["scriptReport"]["console"].to_string().contains("bad.jsxinc:2:"), "{r}"); + assert!(!r.to_string().contains(outside.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() { diff --git a/crates/ui-egui/src/tests/nativemenu.rs b/crates/ui-egui/src/tests/nativemenu.rs index 65bd01999d..689ae8a60c 100644 --- a/crates/ui-egui/src/tests/nativemenu.rs +++ b/crates/ui-egui/src/tests/nativemenu.rs @@ -683,48 +683,51 @@ fn the_app_bar_hides_its_menus_with_the_mac_menu_bar() { /// answers, the menus are as before and work again. #[test] fn the_menu_bar_is_inert_while_a_file_dialog_is_open() { - let mut m = Mac::new(true); - m.app.services.dialogs_are_sheets = true; - let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut m.app.services); - m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap(); - m.app.run("select.none", json!({})).unwrap(); - m.frame(vec![]); let rows = |bar: &MenuBar| bar.items().iter().map(|it| (it.command, it.params.clone(), it.enabled)).collect::>(); - let before = m.fake.last(); - let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE]; - assert!(system.iter().all(|c| find(&before, c).enabled)); - assert!(m.app.run("file.open", json!({})).is_err()); - assert!(m.app.file_dialog_open()); - m.frame(vec![]); - let inert = m.fake.last(); - for it in inert.items() { - let kept = it.command.is_some_and(|c| system.contains(&c)); - assert_eq!(it.enabled, kept, "{:?} {} while the dialog is open", it.command, it.label); - } - // A click that raced the menu going inert isn't run; Minimize is. - let rulers = (m.app.ui.view.rulers, find(&before, "view.rulers").clone()); - m.fake.report(native_menu::Event::Click(rulers.1.clone())); - m.frame(vec![]); - assert_eq!(m.app.ui.view.rulers, rulers.0); - m.fake.report(native_menu::Event::Click(find(&inert, native_menu::MINIMIZE).clone())); - assert!(m.frame(vec![]).viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Minimized(true))); - // A key equivalent that raced it doesn't reach egui. - let mut k = crate::shortcuts::parse("Cmd+A").unwrap(); - k.modifiers.mac_cmd = k.modifiers.command; - m.fake.report(native_menu::Event::Key(k)); - let mut raw = egui::RawInput::default(); - m.app.raw_input_hook(&m.ctx, &mut raw); - assert!(!raw.events.iter().any(|e| matches!(e, Event::Key { .. })), "{:?}", raw.events); - m.key_equivalent("Cmd+A"); - assert_eq!(m.selected(), 0, "⌘A selected nothing"); - // Cancelled: the menus are as before, and work again. - shown.answer(&[]); - m.frame(vec![]); - m.frame(vec![]); - assert!(!m.app.file_dialog_open()); - // Every row from before is back as it was (tests running alongside may add plug-in rows). - let after = rows(&m.fake.last()); - assert!(rows(&before).iter().all(|r| after.contains(r)), "the menus are as before"); + // The menus read before the dialog and after it are compared, so the whole run is undisturbed: + // a test running alongside can add or remove a composite font or a plug-in in between. + let (mut m, (before, after, rulers)) = undisturbed(|m| { + m.app.services.dialogs_are_sheets = true; + let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut m.app.services); + m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap(); + m.app.run("select.none", json!({})).unwrap(); + m.frame(vec![]); + let before = m.fake.last(); + let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE]; + assert!(system.iter().all(|c| find(&before, c).enabled)); + assert!(m.app.run("file.open", json!({})).is_err()); + assert!(m.app.file_dialog_open()); + m.frame(vec![]); + let inert = m.fake.last(); + for it in inert.items() { + let kept = it.command.is_some_and(|c| system.contains(&c)); + assert_eq!(it.enabled, kept, "{:?} {} while the dialog is open", it.command, it.label); + } + // A click that raced the menu going inert isn't run; Minimize is. + let rulers = (m.app.ui.view.rulers, find(&before, "view.rulers").clone()); + m.fake.report(native_menu::Event::Click(rulers.1.clone())); + m.frame(vec![]); + assert_eq!(m.app.ui.view.rulers, rulers.0); + m.fake.report(native_menu::Event::Click(find(&inert, native_menu::MINIMIZE).clone())); + assert!(m.frame(vec![]).viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Minimized(true))); + // A key equivalent that raced it doesn't reach egui. + let mut k = crate::shortcuts::parse("Cmd+A").unwrap(); + k.modifiers.mac_cmd = k.modifiers.command; + m.fake.report(native_menu::Event::Key(k)); + let mut raw = egui::RawInput::default(); + m.app.raw_input_hook(&m.ctx, &mut raw); + assert!(!raw.events.iter().any(|e| matches!(e, Event::Key { .. })), "{:?}", raw.events); + m.key_equivalent("Cmd+A"); + assert_eq!(m.selected(), 0, "⌘A selected nothing"); + // Cancelled: the menus are as before, and work again. + shown.answer(&[]); + m.frame(vec![]); + m.frame(vec![]); + assert!(!m.app.file_dialog_open()); + (rows(&before), rows(&m.fake.last()), rulers) + }); + // Every row from before is back as it was. + assert!(before.iter().all(|r| after.contains(r)), "the menus are as before"); m.fake.report(native_menu::Event::Click(rulers.1)); m.frame(vec![]); assert_eq!(m.app.ui.view.rulers, !rulers.0); diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 45801c22ec..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] @@ -1361,6 +1589,23 @@ fn a_short_drag_released_as_a_click_keeps_its_caret() { assert_eq!(f.frame(vec![enter()], &draw), Some(1273456.0), "typing lands where the drag left the caret"); } +/// #1326: a pointer movement well under egui's click tolerance must keep the caret too; only a +/// press released within a point of where it went down selects all. +#[test] +fn a_sub_threshold_movement_released_as_a_click_keeps_its_caret() { + let mut f = Field::new(); + let draw = |ui: &mut egui::Ui| numeric_fixture(ui, 0); + f.frame(vec![], &draw); + let at = f.text_point(field_text(0), 2, 7.0); + f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw); + let pressed = f.selection(); + let nudge = egui::vec2(1.5, 0.0); + f.frame(vec![Event::PointerMoved(at + nudge)], &draw); + f.frame(vec![pointer_button(at + nudge, false)], &draw); + assert_ne!(f.selection(), Some((0, field_text(0).chars().count())), "a 1.5 px movement is a drag, not a tap (#1326)"); + assert_eq!(f.selection(), pressed, "the movement keeps the caret where the press landed"); +} + /// A tap (no movement) on a fresh numeric field still selects all on release. #[test] fn a_tap_without_movement_still_selects_all_on_release() { @@ -1374,6 +1619,20 @@ fn a_tap_without_movement_still_selects_all_on_release() { assert_eq!(f.selection(), Some((0, field_text(0).chars().count())), "a plain tap selects all"); } +/// A pen or trackpad tap often moves a pixel between press and release: it is still a tap. +#[test] +fn a_tap_with_a_pixel_of_jitter_still_selects_all_on_release() { + let mut f = Field::new(); + let draw = |ui: &mut egui::Ui| numeric_fixture(ui, 0); + f.frame(vec![], &draw); + let at = f.text_point(field_text(0), 2, 7.0); + f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw); + let jitter = egui::vec2(0.7, -0.6); + f.frame(vec![Event::PointerMoved(at + jitter)], &draw); + f.frame(vec![pointer_button(at + jitter, false)], &draw); + assert_eq!(f.selection(), Some((0, field_text(0).chars().count())), "a jittery tap selects all"); +} + #[test] fn keyboard_focus_keeps_selecting_the_entire_numeric_field() { for mode in 0..4 { diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index d42be49ff9..4cece18050 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -208,6 +208,34 @@ fn type_on_a_path_offers_dynamic_text() { assert!(!has(&texts, "Dynamic Text"), "{texts:?}"); } +/// Type on a closed path offers Center on Top and Center on Bottom; type on an open path does not. +#[test] +fn type_on_a_closed_path_offers_center_on_top_and_bottom() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let line = id(app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()); + let open = id(app.run("text.createInPath", json!({"path": line, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()); + select(&mut app, &[open]); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Type on a Path Options") && !has(&texts, "Center on Top") && !has(&texts, "Center on Bottom"), "{texts:?}"); + let circle = id(app.run("path.create", json!({"d": "M100 300 A100 100 0 1 1 300 300 A100 100 0 1 1 100 300 Z"})).unwrap()); + let t = id(app.run("text.createInPath", json!({"path": circle, "mode": "onPath", "text": "Round", "at": [90, 300]})).unwrap()); + select(&mut app, &[t]); + let start = |app: &VectorcraftApp| match kind(app, t) { + NodeKind::Text(t) => match t.kind { + TextKind::OnPath { start, .. } => start, + k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + }; + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Center on Top") && has(&texts, "Center on Bottom"), "{texts:?}"); + click(&mut app, "Center on Top"); + assert!((start(&app) - 0.75).abs() < 1e-3, "{}", start(&app)); + click(&mut app, "Center on Bottom"); + assert!((start(&app) - 0.25).abs() < 1e-3, "{}", start(&app)); +} + /// Area type offers Dynamic Text beside Convert To Point Type, which turns Block on and off; area /// type in a thread does not. #[test] @@ -232,6 +260,22 @@ fn area_type_offers_dynamic_text() { assert!(has(&texts, "Convert To Point Type") && !has(&texts, "Dynamic Text"), "{texts:?}"); } +/// Point type offers Dynamic Text beside Convert To Area Type, which turns it into area type with +/// Block on; the bar then offers what area type does. +#[test] +fn point_type_offers_dynamic_text() { + let mut app = app(); + let t = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Fit\nme in a frame"})).unwrap()); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + select(&mut app, &[t]); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Convert To Area Type") && has(&texts, "Dynamic Text"), "{texts:?}"); + click(&mut app, "Dynamic Text"); + assert!(matches!(kind(&app, t), NodeKind::Text(ref t) if t.dynamic_text && matches!(t.kind, TextKind::Area { .. }))); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Convert To Point Type") && has(&texts, "Dynamic Text"), "{texts:?}"); +} + /// The path's anchors (its first subpath). fn anchors(app: &VectorcraftApp, id: u64) -> Vec { match kind(app, id) { diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs index 017ba588ce..87c6034bda 100644 --- a/crates/ui-egui/src/widgets/chips.rs +++ b/crates/ui-egui/src/widgets/chips.rs @@ -111,7 +111,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/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/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index 3551baebd2..eec3cd3a94 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -147,10 +147,14 @@ pub(crate) fn own_field_shift_enter(ui: &Ui, id: egui::Id, buf: &mut String, sho shift_enter_keeps_focus(ui, id) } +/// How far, in points, a focus click may move between press and release and still be a tap that +/// selects the whole numeric value. Covers pen and trackpad jitter; anything further is a drag. +const TAP_SLOP: f32 = 1.0; + /// Numeric fields select the whole text on keyboard focus, a completed focus click or a /// double-click. A pointer press keeps its caret so dragging selects from the pressed position, /// and a short drag the toolkit still counts as a click keeps it too (#1326): only a tap -/// releases into select-all. +/// (released within [`TAP_SLOP`] of its press) releases into select-all. pub(super) fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { let pending = resp.id.with("select-all-on-focus-click"); let press_pos = resp.id.with("select-all-on-focus-press"); @@ -171,13 +175,14 @@ pub(super) fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { }); } let clicked_focus = released && ui.data_mut(|d| d.remove_temp::(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary); - // A press released a few points away is a short drag, not a tap. egui still reports a - // click, and a release inside one character leaves no text selection, so select-all on - // release would replace the drag the user just made (#1326). + // A pointer released more than a point from its press is a drag, not a tap. egui still + // reports a click for a movement under its click tolerance, and a release inside one + // character leaves no text selection, so select-all on release would replace the drag the + // user just made (#1326). The one point of slack keeps a jittery pen or trackpad tap a tap. let dragged = clicked_focus && ui .data_mut(|d| d.remove_temp::(press_pos)) - .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 3.0)); + .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > TAP_SLOP)); if resp.lost_focus() { ui.data_mut(|d| { d.remove_temp::(pending); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6619cffd6f..d1f1fce598 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 @@ -111,7 +111,7 @@ the two can still win that race. | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | -| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. A save or export that writes other files beside that path (a file per artboard, linked images, image maps) checks them before it writes anything: when any of them exists, it writes nothing and opens a `replaceFiles` dialog (fields `message`, `detail` (each file's name when there are several), `files`, `__command`, `__params`; the reply is `{"pending": "replaceFiles"}`, or comes once a Background Save or Export ends): `ui.dialog.confirm` runs the save or export again and replaces them, `ui.dialog.cancel` writes nothing; a dialog open when a background job asks stays open, and the question follows it. A save does not ask about the files the document's last Save or Save As in this session wrote. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | +| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. A save or export that writes other files beside that path (a file per artboard, linked images, image maps) checks them before it writes anything: when any of them exists, it writes nothing and opens a `replaceFiles` dialog (fields `message`, `detail` (each file's name when there are several), `files`, `__command`, `__params`; the reply is `{"pending": "replaceFiles"}`, or comes once a Background Save or Export ends): `ui.dialog.confirm` runs the save or export again and replaces them, `ui.dialog.cancel` writes nothing; a dialog open when a background job asks stays open, and the question follows it. A save does not ask about the files the document's last Save or Save As in this session wrote. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab. Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centered in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). In the desktop app the dialog reads its files off the UI thread: `__info` is empty until they are read, `ui.dialog.confirm` returns `{background: true, status}`, `ui.inspect` lists the files still being read in `placing`, and the file is placed (or the cursor loaded) once it is read. With a file, it runs `file.place` centered in the view, at once. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`, `.vcexportpresets`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files (in the desktop app each file is read off the UI thread and placed once it is read, and `ui.inspect` lists it in `placing` until then) and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, width?, height?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in PNG and JPEG files (WebP files store no resolution); `width` or `height` sets the size of each file in pixels instead (`width` wins), and PNG and JPEG files store the resolution that results; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | @@ -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 @@ -987,6 +987,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 @@ -1082,13 +1099,35 @@ 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-24 | minor | Documented the brush library panel commands (`window.brushLibrary`, `.other`, `ui.saveBrushLibrary`, `window.userBrushLibrary1`…`10`). | -| 2026-10-24 | minor | Documented `ui.screenshot`'s 64 MiB inline-PNG reply cap: with `path` the file is still written and `pngBase64` is omitted when over the cap; a path-less `data: true` request fails before encoding. | | 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). | | 2026-10-25 | minor | Documented that Transform Each opens on the values it was last confirmed with and that its Copy button confirms with `copy: true` (#1732). | +| 2026-10-24 | minor | Documented the brush library panel commands (`window.brushLibrary`, `.other`, `ui.saveBrushLibrary`, `window.userBrushLibrary1`…`10`). | +| 2026-10-24 | minor | Documented `ui.screenshot`'s 64 MiB inline-PNG reply cap: with `path` the file is still written and `pngBase64` is omitted when over the cap; a path-less `data: true` request fails before encoding. | | 2026-10-23 | minor | Documented the symbol library panel commands (`window.symbolLibrary`, `.other`, `ui.saveSymbolLibrary`, `window.userSymbolLibrary1`…`10`). | +| 2026-10-18 | minor | Documented `window.vectorcraft`, the web build's control channel. | | 2026-10-13 | minor | Documented count, pending-event and per-frame request budgets (#1709). | | 2026-10-13 | minor | Documented transport deadlines and connection-slot recovery (#1726). | -| 2026-10-18 | minor | Documented `window.vectorcraft`, the web build's control channel. | + +## Save location errors + +A save refused by the filesystem with PermissionDenied or ReadOnlyFilesystem returns +`READ_ONLY_LOCATION: `. The reason retains the path and system message. This is a +stable prefix within the existing error string, not a new response field or JSON-RPC code. +An automation-root denial, an encoding failure, and an error retaining recovery copies do +not use this code. Choose a writable destination and explicitly save there; automation never +opens the fallback Save As panel. Failed saves keep the original document path and modified +state; multi-file saves may already have written other output files. + +`app.save` with `wait: true` returns the write failure in its reply (`save_file` already asks +for this on the desktop). Without waiting, an accepted background save still replies before +completion; any later failure opens no panel. MCP retains its usual tool-name prefix around +the error text. The interactive fallback applies only to ordinary File > Save, not Close/Quit. + +## Revision history + +| Date | Change | +|---|---| +| 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/development.md b/docs/development.md index 2362fc6f1a..37f2b770ac 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,7 @@ # Development -> **Last updated:** 2026-11-06 · **Change:** Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending. +> **Last updated:** 2026-11-12 · **Change:** Tests canonicalize temporary roots and normalize Windows verbatim prefixes before comparing preset refusal paths or probing a case-variant script folder (#6860); production access checks unchanged. +> **Last updated:** 2026-11-12 · **Change:** minor (clipboard failure helper follows its Linux/BSD and test callers; previously: Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending). > **Last updated:** 2026-11-01 · **Change:** Linux: Automatic Linux Display Server opens the window through XWayland when a pen is attached on a Wayland session, so the pen draws (#4967); `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. > **Last updated:** 2026-10-31 · **Change:** files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance`. > **Last updated:** 2026-10-29 · **Change:** Linux clipboard: the Flatpak on GNOME copies and pastes text through the core Wayland protocol. @@ -351,6 +352,10 @@ parameters. Shipped code therefore never panics. Anything that can fail returns ### Enforced by lints +The clipboard timeout failure helper is compiled for Linux/BSD workers and unit tests; production +macOS reads synchronously. Keep helper target gates aligned with their callers instead of disabling +dead-code warnings across the workspace. + `Cargo.toml` denies these clippy lints for the whole workspace, and `cargo xtask ci` runs clippy with `-D warnings`. The gate targets the current stable Rust (`rust-version` in `Cargo.toml` is only the minimum that builds the app, and no toolchain is pinned): when a new stable adds lints, they are fixed @@ -508,6 +513,8 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Tests (#6860): compare preset refusal roots after canonicalization and Windows display normalization; canonicalize the script fixture base before constructing `MAIN`. This keeps macOS `/var` → `/private/var` aliases from masking the case-sensitivity checks; no production-code change. | +| 2026-11-12 | minor | Compile the clipboard failure helper only for Linux/BSD worker callers and unit tests; keep production macOS free of the unused helper without suppressing workspace dead-code warnings. | | 2026-11-06 | minor | Localisation: Hindi catalog, zero/one plurals, desktop and web font requirements; AI/MT draft, native review pending. | | 2026-10-31 | minor | Files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance` opts out. | | 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME).; its reads wait 2 s at most. | diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 568eaa75e5..6b14fa0907 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -28,7 +28,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Rasterize | Rasterize… (as a live effect) | 1/1 | — | | Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | | SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists the document's own filters: VectorCraft ships no default filters. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning | -| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; Arc is a circular arc in the object's own proportions (#1578) | +| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; each style bends the bounds as one Bézier patch (`version` 1), so Arc's edges are cubic curves close to concentric arcs; warps saved without a version keep the earlier map, where Arc is a circular arc in the object's own proportions (#1578) | Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers its resolution, PDF export warns once per object with the requested and the lowest achieved ppi diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e2721d431b..9db1e9f90a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.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 (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) 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 @@ -15,6 +15,8 @@ from the public specifications, and users' own `.ai` files are used locally only ## Summary +Ordinary File > Save offers Save As when its own location refuses writes with a terminal permission/read-only error (#6146). The picker suggests the original filename and format, keeps the original options when the format is unchanged, and opens the chosen format’s Save options when it changes. It starts in Documents (home or host default if unavailable). Cancel leaves the document modified at its original path. Explicit Save As/Copy/export, recovery failures and Close/Quit keep their existing error dialog. Non-UI saves return `READ_ONLY_LOCATION: ` without opening a panel. Focused UI regressions passed; engine regression execution and native panel acceptance pending; format scores unchanged. + | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS | @@ -108,7 +110,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, @@ -155,7 +167,8 @@ private editing data. VectorCraft: under them, clipped to where they show, along a smooth outline traced at the image's own resolution; that art stays) or omitted (Transparent Images, `transparentImages: "flatten"|"omit"`). Type is written as editable text objects (point type, area type and type on a path, with their fonts' PostScript names, sizes, colours, alignment, indents, leading, tracking and - scaling), which the reading app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline + scaling, and the Standard Vertical Roman Alignment and Tate-chu-yoko marks as `Tv` and `TY`), which the reading + app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline graphics and gradient or pattern fills make that type outlines, and so do rotated characters (Character Rotation, which a version 7 text object can't hold: the outlines keep the rotation, and the type is no longer editable text in the file), and Text: Outlines (`outlineText: true`) writes @@ -287,7 +300,7 @@ 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`) | | 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 | @@ -340,7 +353,13 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 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). | +| 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. | | 2026-11-11 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: type's strokes in an envelope (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 (used locally, never committed: there the drawn art keeps those strokes as stroked paths at the type's own weights, and its ring is as wide wherever the grid squeezes or stretches the type). With Distort Appearance, VectorCraft bent them with the art, as their filled outlines, so an envelope of stroked type in a squeezed or stretched grid didn't draw as its drawn art and kept it. The same holds in an envelope inside an envelope. Paths' strokes, geometry effects, opacity masks, linear gradients and pattern fills bend as before. On 224 of our own files: envelopes of type that open live go from 135 in 37 files to 154 in 45, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 31 files, by at most 2.3% of their pixels, 19 closer to the file's own page and 12 further, 11 of those where an envelope of type now opens live in place of its drawn art (the page's own art) and one by 4 pixels. Tests `envelope_distort::type_strokes_paint_at_their_own_weight_in_a_squeezed_envelope`, `a_stroke_on_types_appearance_paints_at_its_own_weight_in_a_squeezed_envelope`, `a_paths_stroke_still_bends_in_a_squeezed_envelope`, `tests_native::an_envelope_of_stroked_type_whose_drawn_art_strokes_the_bent_outlines_at_the_weight_opens_live` (#6644) | +| 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 | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | | 2026-11-11 | minor | `.ai` save (PDF-compatible editing copy and legacy) and `.ai`/EPS open: a layer's `Lb` colour index follows the file's numbering, where Magenta, Cyan and Yellow are 4, 5 and 6 and `LAYER_COLORS` has Yellow, Magenta and Cyan; Yellow, Magenta and Cyan layers no longer come out as Magenta, Cyan and Yellow, and the other 24 presets are unchanged (#6502, from the index and RGB pairs in users' files). `LAYER_COLORS` keeps its order, so `.vectorcraft` files and the Layers panel don't change. Scores and estimates unchanged. | @@ -367,14 +386,14 @@ Stop opacity on translated artboards remains outside this regression coverage. | 2026-11-07 | minor | EPS import: an embedded Type 1 font's encrypted part (`eexec`, [Adobe Type 1 Font Format](https://adobe-type-tools.github.io/font-tech-notes/pdfs/T1_SPEC.pdf), chapter 7) is no longer only skipped: its charstrings are read, and the font dictionary gets the `CharStrings` dictionary the part would have given it. Its type then takes its characters from its glyph names (the standard and ISO Latin-1 encodings by their codes' names, PLRM 3rd ed., Appendix E), its widths move the current point and space the live type as tracking (as Type 42 fonts do), `stringwidth` and `charpath` read its glyphs, glyphs whose names say no character are drawn from its outlines, and a composite font's Type 1 descendants draw their glyphs. A font found by name, re-encoded with glyph names at other codes (codes 1 to 12 for `T`, `r`, `a`… in pdftocairo's subsets), sets the characters its names say instead of its codes read as Latin-1 (such type was left out). | | 2026-11-07 | minor | EPS import: CID-keyed fonts (PLRM 3rd ed., §5.11; [Adobe Technical Note #5014](https://adobe-type-tools.github.io/font-tech-notes/pdfs/5014.CIDFont_Spec.pdf)) draw from the font data the file carries instead of stopping the program at `composefont` or the CIDInit operators: CMaps built with `begincmap` … `endcmap` (codespace ranges, CID and notdef mappings, `usefont`, `usecmap`; mappings to base-font codes are not read) and the predefined `Identity-H` and `Identity-V`; `composefont` and `FMapType` 9 fonts, whose codes take as many bytes as their codespace range says, an invalid or unmapped code falling back as §5.11.5 says; Type 0 CIDFonts' Type 1 charstrings, read by `StartData` (binary or hexadecimal) or from a `GlyphDirectory`, with their FDArray dictionaries' matrices and subroutines (`Subrs` or the binary data's subroutine table); Type 2 CIDFonts' TrueType glyphs through `CIDMap` (strings, a dictionary or an offset); `Metrics` widths; vertical writing mode set downwards. A CIDFont the file doesn't carry draws nothing, with a warning; FontSet (Compact Font Format) data is skipped. | | 2026-11-07 | minor | EPS import: a plain Type 42 font that carries its TrueType font ([Adobe Technical Note #5012](https://adobe-type-tools.github.io/font-tech-notes/pdfs/5012.Type42_Spec.pdf)) gives each code its character by its glyph name (single characters, `uniXXXX`, the standard encoding's and Windows-1252's names) or, when the name says nothing (`/g12`), by the glyph `CharStrings` gives it in the font's own character map or `post` names, instead of reading the code as Latin-1 (a Euro at code 128 was dropped); the font's own widths move the current point (`stringwidth`, `show`), `charpath` outlines its glyphs, and a string with a glyph that stands for no character is drawn from the font's outlines. Live type keeps the spacing the file gives it: where the show operator's numbers (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`: PLRM 3rd ed., chapter 8) or the embedded font's widths differ from the installed or fallback font's advances, each character's tracking makes up the difference, as PDF import does, so justified type in a font that isn't installed no longer drifts (pdftocairo and pdftops EPS of a justified PDF page set in a missing font: 10.2% and 9.0% of the page differing from Ghostscript's render, now 6.2% and 5.8%). Type under `nulldevice` no longer shows. | -| 2026-11-06 | minor | EPS import: the buffers an image decoder makes for itself (its sample planes, its copies of the strings a procedure data source hands back, and the split buffers of an interleaved masked image) stop counting against the PostScript reader's 1 GiB memory budget once the image is decoded, as they are freed then; the pixels kept, and anything the program's own procedures allocate, still count. A user's Illustrator 25.2 EPS with 71 CMYK images (used locally, not committed) used to stop with "the program uses too much memory" and come in as its screen preview; it now opens with its art. The budget itself is unchanged. Scores and estimates unchanged. | | 2026-11-07 | minor | EPS import: a sampled function (type 0) of two or more inputs took the nearest sample, so a function-based shading (type 1) over one came in as flat blocks; it is now interpolated between its samples (multilinearly), and with `Order 3` by cubics through them (Catmull-Rom, kept in the function's range), as the PostScript Language Reference (3rd ed., §3.10.1, type 0 functions) defines. An axial or radial shading whose function is a linearly interpolated sampled function of one input has a stop at each sample, not 33 evenly spaced ones. Tests `sampled_functions_interpolate_linearly_or_by_cubics`, `mesh_and_function_shadings_become_gradient_meshes`; a 2 × 2 function-based shading is 1.2% from Ghostscript's render (it was 45.9%); scores unchanged | | 2026-11-07 | minor | EPS and `.ai` type from the text document keeps its paragraphs' tab stops (positions and alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. A user's `.ai` (used locally, not committed) whose price lists came in on the default stops now lays them out as its page has them (its type 0.8% from its page, from 1.5%; what differs is hyphenation in Hungarian and type in a font that isn't installed). Scores and estimates unchanged. | | 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type of one frame whose last lines the file sets in too short a frame for them here may get a frame taller by its lines' deepest descent plus 0.05 pt, when that shows more of them. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. | +| 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. | +| 2026-11-06 | minor | EPS import: the buffers an image decoder makes for itself (its sample planes, its copies of the strings a procedure data source hands back, and the split buffers of an interleaved masked image) stop counting against the PostScript reader's 1 GiB memory budget once the image is decoded, as they are freed then; the pixels kept, and anything the program's own procedures allocate, still count. A user's Illustrator 25.2 EPS with 71 CMYK images (used locally, not committed) used to stop with "the program uses too much memory" and come in as its screen preview; it now opens with its art. The budget itself is unchanged. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a path or compound path whose art style stacks several fills and strokes (solid colours and gradients, each with its own visibility, opacity and blend mode, under effects any object takes) comes in as itself with that appearance instead of its drawn look. Each gradient must be its own swatch, placed as the drawn look places it; a stack that repeats a swatch, or whose look doesn't paint its gradients, keeps the drawn look, as do fills and strokes with effects of their own. | | 2026-11-06 | minor | `.ai` and EPS import: Art brushes whose objects are in the older form (first number 1, one field fewer and 8 numbers at the end) come into the Brushes panel with their art instead of being left out with a note. In a survey of 302 users' EPS files (used locally, not committed), 652 Art brushes in 188 of the 197 files with the note were left out this way; afterwards 55 files have the note, all for Pattern brushes. Test `an_older_art_brush_comes_in_with_its_art` (crate `vectorcraft-eps`); scores unchanged | | 2026-11-06 | minor | EPS import: composite (Type 0) fonts with the 8/8 mapping (`FMapType` 2: PLRM 3rd ed., §5.10) draw a Type 42 descendant's glyphs from the TrueType outlines its `sfnts` carry (Adobe Technical Note #5012), spaced by their own widths or the show operator's numbers, and `charpath` and `stringwidth` read them; descendants found by name set their characters as type at the composite font's size instead of a thousand times it. EPS from `pdftops` of a PDF with embedded TrueType fonts showed nothing, or one black shape, where its type was. | -| 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: an art style's stroke aligned inside or outside its path (Stroke panel › Align Stroke, written as its own `Adobe Stroke Offset` filter) comes in as a stroke with that alignment, so objects whose style has one keep the style live instead of coming in as their drawn look. Without its inside/outside switch the drawn look stays. | | 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | @@ -399,8 +418,8 @@ Stop opacity on translated artboards remains outside this regression coverage. | 2026-10-31 | minor | `.ai`/EPS editing data: type made from the file's text document took each character attribute its run doesn't set from the document's base attributes, not from the character style the run names (`[Normal Character Style]` or another of the file's, and the styles each is based on). Where they differ the type came in wrong: a user's file whose base fill is white and whose Normal Character Style is black came in with its text white, so most of its type was missing wherever the page's own type didn't stand in for it. A run now takes its own attributes, then its character style's, then the document's ([Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html)). Test `import::tests_native::type_from_the_text_document_takes_its_character_styles_attributes`; checked locally against 377 users' files: none newly opens from its PDF part, and with #4791 that file opens from its layers (6.3% → 1.1% from its page); scores unchanged | | 2026-10-31 | minor | SVG export (and Object › Expand, EPS, DXF, EMF and the other outputs that cut a gradient mesh into flat pieces): each patch was cut into 8 × 8 quads whatever its colours, so a patch of one colour became 64 identical pieces. Neighbouring quads are now joined into square blocks wherever the colour changes by no more than about one 8-bit step across the block, so a flat patch is one piece and a patch that shades strongly keeps all 64; a block's outline runs through its quads' corners, and a patch that folds over itself is not joined. A synthetic page of 400 meshes (6 × 6 patches, half flat) exports 468,000 pieces instead of 921,600 (35 MB instead of 66 MB), and resvg draws the two files identically. Tests `mesh_export` (crate `vectorcraft-svg`), `tests::live::mesh_expand_to_flat_pieces`; scores unchanged | | 2026-10-31 | minor | PDF import (and a `.ai`'s PDF part): a transparency group's knockout and isolate flags (`/K`, `/I`; ISO 32000-1, 11.4.6.3) come from the interpreter as it pushes the group. They were read from the file in order and matched to the groups pushed, but a group that adds nothing (full opacity, Normal, no mask, neither flag) is drawn straight onto its parent without being pushed, so every group after one lost its own flags and took another's; and a page drawing form groups more than 10,000 times failed to import (the list of their flags was capped), which it no longer does. A blend's steps written as a knockout group of falling opacity drew as solid dark shapes: a user's `.ai` with 143 such bubbles now draws them as light rings, as poppler does (its bubble area 4.9% → 1.2% of pixels off from poppler's render); 16 other users' PDFs with transparency groups (44 pages) draw the same. Test `tests::import_fidelity::knockout_groups_after_a_group_drawn_straight_keep_their_flag`; scores unchanged | -| 2026-10-30 | minor | `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout; blends are written with knockout on) in the state block after its record, as a group takes the one after its end; it was left for the next object, so a blend's steps of falling opacity drew over each other instead of knocking each other out, and every such blend came in too dark (a user's file has 143, its bubbles came in as dark rings). Both a live blend and drawn steps kept as a group take it. Test `import::ai::tests::a_blends_transparency_follows_its_record`; checked locally against that file: it comes 1.3% → 0.6% nearer its page, though a mesh seen through a mesh-shaped opacity mask still keeps it just over the limit. When a blend is the layer's last object and the layer's own state block (`7 () XW`, then `LB`) follows, the state is the layer's and the blend keeps its own, as for meshes (#4748); scores unchanged | | 2026-10-31 | minor | PDF (and `.ai`) with Illustrator editing data saved with only some of its artboards: its pages are those artboards, while the editing data keeps them all, and the layers were compared with the first page on the first artboard, so a page of the second artboard came in as the PDF part, without its layers. With fewer pages than artboards, the first page is now taken to be the artboard of its size whose art draws most like it (up to 16 weighed), and the pages' type goes to that artboard and the ones after it. All artboards come in, as before. Test `import::tests_native::a_pdf_of_its_second_artboard_is_compared_on_that_artboard`; scores unchanged | +| 2026-10-30 | minor | `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout; blends are written with knockout on) in the state block after its record, as a group takes the one after its end; it was left for the next object, so a blend's steps of falling opacity drew over each other instead of knocking each other out, and every such blend came in too dark (a user's file has 143, its bubbles came in as dark rings). Both a live blend and drawn steps kept as a group take it. Test `import::ai::tests::a_blends_transparency_follows_its_record`; checked locally against that file: it comes 1.3% → 0.6% nearer its page, though a mesh seen through a mesh-shaped opacity mask still keeps it just over the limit. When a blend is the layer's last object and the layer's own state block (`7 () XW`, then `LB`) follows, the state is the layer's and the blend keeps its own, as for meshes (#4748); scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout; blends are written with knockout on) in the state block after its record, as a group takes the one after its end; it was left for the next object, so a blend's steps of falling opacity drew over each other instead of knocking each other out, and every such blend came in too dark (a user's file has 143, its bubbles came in as dark rings). Both a live blend and drawn steps kept as a group take it. Test `import::ai::tests::a_blends_transparency_follows_its_record`; checked locally against that file: it comes 1.3% → 0.6% nearer its page, though a mesh seen through a mesh-shaped opacity mask still keeps it just over the limit; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh with an opacity mask takes the transparency (`Xy`) written after the mask's dictionary, as one without a mask takes the one right after its `/End X!`; the operators of the mask's art (on `%_` lines) hid it, so the state was read as the next object's and each masked mesh in a run drew with the blend mode of the one before it (a Multiply shading drawn Normal, an Overlay tint drawn Multiply over the art below). Test `import::ai::tests::a_masked_gradient_mesh_takes_the_transparency_written_after_its_mask`; checked locally against a user's files: one opens from its layers 0.1% from its page (it was 0.6%, just inside the limit), another comes 1.6% → 1.3% nearer; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a layer's own state block (`Xy`, `Xd`, `7 () XW` before `LB`) can have the stroke style (`J`, `j`, `w`, `M`, `d`, `XR`) between its `Xy` and `Xd`; read as the art moving on, it lost the layer's opacity, so a layer at 17% drew opaque over the art under it and the file came in as its page. When the layer's last object is a gradient mesh, the mesh keeps its own state through the stroke style too (#4748). An object's own state block (`6 () XW`) still isn't the layer's. Of 408 users' files whose editing data was checked, 3 have such a block: an EPS that now opens from its layers 0.5% from its page (it was 18.8%), one whose layer now draws at 85% as its page does, and one whose layers are all at 100%; the others draw as before. Test `import::ai::tests::a_layers_state_keeps_through_the_stroke_style_written_in_it`; scores unchanged | @@ -424,9 +443,9 @@ Stop opacity on translated artboards remains outside this regression coverage. | 2026-10-30 | minor | `.ai` import: a PDF part counts as an empty page only when nothing on it was skipped (an image that can't be decoded draws nothing too), so such a page keeps its notes and is checked against the editing data as before; with `textAs: "outlines"`, a file read from its editing data alone (an empty page, or the text-only placeholder of #1387) has its type outlined. Tests in `tests_aiimport.rs`; scores and hours unchanged | | 2026-10-30 | minor | `.ai` import: a file whose PDF part is an empty page opens from its editing data where that reads, instead of opening blank; scores unchanged | | 2026-10-30 | minor | `.ai` import: a file whose PDF part's pages are cropped to its art rather than its artboards no longer comes in as that PDF part (each page a copy of all the art) but from its layers, with its artboards, where they draw as the page does; own-fixture test in `tests_aiimport`; scores unchanged | +| 2026-10-30 | minor | EMF export: a pattern fill whose tiles stay apart (grid and brick tilings, art inside its tile) is written as one shape for each part of the tile, clipped once to what it fills, instead of every tile in a clip of its own; the picture is the same. A rotated two-colour stripe fill goes from 473 shapes and 246 clips to 2 shapes and 1 clip (122 KB to 61 KB; unrotated 99 KB to 33 KB). Tiles that overlap, hex tilings and tiles with strokes, images or nested clips are written as before (#3911). Tests in `crates/metafile/src/tests.rs` and `scene::merge_tests`; scores and hours unchanged | | 2026-10-29 | minor | `.ai` and Illustrator EPS import: the editing data is read up to an eighth of installed memory, from 512 MiB to 2 GiB on 64-bit builds (it was 512 MiB on every machine), so a layered file whose embedded rasters unpack past 512 MiB keeps its layers, groups and text on a machine with 8 GiB or more instead of opening as its page or PDF part (#3510, #4628). Measured: 1.1 GB of editing data peaked 1.1 GB above the start; data past the limit is still refused (a 2 MB file unpacking to 2.2 GB stops in under a second). 32-bit and web builds keep 256 MiB. Own-fixture tests `import::ai::tests::editing_data_past_512_mib_is_read_where_memory_allows`, `the_editing_data_limit_follows_installed_memory`; scores and hours unchanged | | 2026-10-29 | minor | Affinity import: attached Recolor Adjustment (RcRA) layers import as color overlays with HSL lightness (#4420); scores unchanged. | -| 2026-10-30 | minor | EMF export: a pattern fill whose tiles stay apart (grid and brick tilings, art inside its tile) is written as one shape for each part of the tile, clipped once to what it fills, instead of every tile in a clip of its own; the picture is the same. A rotated two-colour stripe fill goes from 473 shapes and 246 clips to 2 shapes and 1 clip (122 KB to 61 KB; unrotated 99 KB to 33 KB). Tiles that overlap, hex tilings and tiles with strokes, images or nested clips are written as before (#3911). Tests in `crates/metafile/src/tests.rs` and `scene::merge_tests`; scores and hours unchanged | | 2026-10-26 | minor | EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; Save As leaves out the export-only crop and selection options. Synthetic regressions in `tests::save` (engine and UI) and `dialogs::save_options::tests`, listed under [EPS Save and source protection](#eps-save-and-source-protection); it does not write Illustrator's own editing data (#868), and G9's real-file import fidelity is unchanged; scores and hours unchanged | | 2026-10-25 | minor | `.ai` import: the editing data is read up to 512 MiB on 64-bit builds (the limit was 256 MiB), so a file whose embedded rasters unpack to 300 MiB or more keeps its layers, groups and text instead of opening as its PDF part (part of #3510); 32-bit and web builds keep 256 MiB. Own-fixture test `import::ai::tests::editing_data_past_256_mib_is_read`; scores and hours unchanged | | 2026-10-25 | minor | `.ai` import: the `--` placeholder of a radial gradient's highlight (`-- -- angle length Bh`, a gradient without one) and the `XC` operator after a linked image's file name no longer send the whole file to its PDF part, so files with them keep their layers, groups and live text (#4176). A `--` anywhere else is still an operator the reader doesn't know. Own-fixture tests in `import::ai::tests`; scores and hours unchanged | @@ -452,15 +471,15 @@ Stop opacity on translated artboards remains outside this regression coverage. | 2026-10-16 | minor | SVG: CSS classes on imported elements (`class="hero"`) are kept on the object and written back on export, beside the generated `cls-N` classes (#2414); scores unchanged (estimated) | | 2026-10-14 | minor | Legacy `.ai` of version 7 runs as a PostScript program: its prolog is a procedure set of VectorCraft's own that gives each operator the writer uses its meaning (paths, colours, compound paths, clipping groups, layers that don't print, images; gradients flat, type not set), and the page trailer's `annotatepage` (§3 of the specification) marks nothing; every file the writer's tests write is run through VectorCraft's PostScript reader and must draw what the document it was written from draws (#868) | | 2026-10-14 | minor | SVG import: `:not()` selectors (#1799, #1557), stacked descendant selectors over deep nesting no longer stall (#1834, #1703), linked files within the automation roots again (#1870); DXF export writes the VPORT view direction once (#1789); nesting capped at 32 objects with self-using symbols and patterns opened without the offending instance (#1852); deep and self-referencing PDF and `.ai` data (#1871), decompression bombs (#1873), self-drawing brushes (#1872), hostile EPS, Affinity and native data (#1863) and huge ids (#1862) handled; a failed save is reported in a dialog and the old file is kept (#1865, #1685, #1693, #1694); type inside symbols gets its layout bounds back on opening a native file (#1833, #1738). Scores unchanged (estimated) | -| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | | 2026-10-14 | minor | EPS import: a stitched shading keeps its hard colour changes (#1654, #1653); WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774); Linked files that change during a drag are updated after the drag instead of being missed (#1773); scores unchanged | +| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | | 2026-10-13 | minor | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620); A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604); A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613); scores unchanged (estimated) | | 2026-10-13 | minor | PDF.R1: editable PDF import uses the shared pinned reader; layer and overprint scans preserve state across content windows and reject known incomplete input. Synthetic streaming, decoder-limit and dependency-source regressions added; scores and hours unchanged. | | 2026-10-13 | minor | PDF.W1: PDF export and print composition use the shared guarded writer; drawing, colour policy and editable import stay app-owned. Metadata regression coverage and immutable dependency checks added; scores and hours unchanged. | | 2026-10-13 | minor | Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479); a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542) (the `.ai` row for #1528 was written by that PR); scores unchanged (estimated) | | 2026-10-13 | minor | `.ai` read as its PDF part: a page whose art reaches past its artboard to the left (notes beside a design) moves right in the row until that art clears the page before it, so it is no longer taken for that page's art; a multi-artboard `.ai` with such notes opened as its PDF part ("the art on them differs from the page's"), without the art outside its artboards, and now opens from its editing data | -| 2026-10-12 | minor | Legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), #868): written from the published specification, keeping layers, groups, compound paths, clipping groups, gradients and images, one artboard per file chosen in an artboard picker; type as editable text objects (point, area and on a path, fonts by PostScript name, Windows-1252), or outlines (Text: Outlines); hidden objects, which the format can't hide, in a hidden layer of their own, omitted or made visible (Hidden Objects); images with see-through pixels flattened with the art under them or omitted (Transparent Images). The `.ai` reader reads legacy type (`To` … `TO`) and `Ar`. `vectorcraft-cli convert` takes `--ai-version 7`, `--hidden-objects` and `--transparent-images`; scores unchanged (estimated) | | 2026-10-13 | minor | TIFF: grayscale files with an alpha channel or other extra channels place, relink and open, read as CMYK files with alpha already were (8 and 16 bits, either polarity; #1489, part of #1466); the rows for #1434 (strokes kept apart from a faded or masked fill, a `.ai`'s RGB numbers) and #1435 (SVG knockout groups without masks, `--decimals`) were written by those PRs; scores unchanged (estimated) | +| 2026-10-12 | minor | Legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), #868): written from the published specification, keeping layers, groups, compound paths, clipping groups, gradients and images, one artboard per file chosen in an artboard picker; type as editable text objects (point, area and on a path, fonts by PostScript name, Windows-1252), or outlines (Text: Outlines); hidden objects, which the format can't hide, in a hidden layer of their own, omitted or made visible (Hidden Objects); images with see-through pixels flattened with the art under them or omitted (Transparent Images). The `.ai` reader reads legacy type (`To` … `TO`) and `Ar`. `vectorcraft-cli convert` takes `--ai-version 7`, `--hidden-objects` and `--transparent-images`; scores unchanged (estimated) | | 2026-10-12 | minor | TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged | | 2026-10-12 | minor | Affinity import: a text frame too short for its first line (a 64 pt title in a 54 pt frame) is made taller from its top, with a warning in the open result, instead of showing nothing (#1423), as `.ai` area type is (#1392); the spacing Affinity itself fits the line with isn't read; scores unchanged | | 2026-10-12 | minor | `.ai` editing data: a compound path takes the paint, opacity, blend mode and fill rule of its last path (the last painted one when that has none), so gradient-filled compound paths no longer come in black, and paths grouped inside a compound path stay its paths (#1426, fixes #1410); hand-written editing data test; scores unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index c462fe19db..09c31c8ae8 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-11 · **Change:** minor (Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -34,29 +34,29 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | +| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 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-04 | minor | PR #3273: 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. | -| 2026-11-04 | minor | PR #3272: Group Selection component drags defer ancestor selection until a completed click, using current Direct Selection release handling; original Outline Stroke fills and newer selection behavior retained. Scores and estimates unchanged. | -| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | -| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, 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-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. | | 2026-11-05 | minor | G15: Selection skips building corner and pie widgets when the pointer is well outside a large selection; empty-canvas hover with 20,000 selected paths took 28.72 ms before and 16.41 ms after in local median runs. Other cursor targets remain active; estimate unchanged. | | 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; after the path hit-test improvement, a 200,000-anchor path's segment cursor took 3.72 ms before and 2.66 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. | | 2026-11-05 | minor | G15: large-selection transforms reuse object locations found during command validation; a 20,000-object nudge took 58.07 ms before and 51.26 ms after in paired median runs. Estimate 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 | G9, G14: `.ai` paths with a Calligraphic brush open brushed (the brush on the stroke, at its weight) where the object's settings are the brush's own, and the brush's Tilt, Bearing and Rotation codes come in as those channels; Windows reads the pen's tilt and barrel rotation from Windows Ink (#372). Checked against a user's file of 6D Art Pen strokes, used locally; not tried with a pen. Scores and estimates unchanged. | -| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | -| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | | 2026-11-05 | minor | G12: Hyphenation › Default Language sets the language of new type unless Document Setup chose one, stored as a tag; each run hyphenates in its own language, English only so far, so 2 Preferences options stay unused (#394) | | 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): Object › Plug-ins › Compound Shapes lists Make, Release and Expand Compound Shape, and holds a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons make a compound shape on a click and give the permanent result on Alt-click (the default is the reverse). Selected members of a compound shape always take the clicked mode. The setting is `window.pathfinder.compoundShapes`; no existing behavior changes while it is off. Scores and estimates unchanged. | +| 2026-11-04 | minor | PR #3273: 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. | +| 2026-11-04 | minor | PR #3272: Group Selection component drags defer ancestor selection until a completed click, using current Direct Selection release handling; original Outline Stroke fills and newer selection behavior retained. Scores and estimates unchanged. | +| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | +| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | +| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-02 | minor | Font-family fields step selected text with the mouse wheel (#5253); no scored gap or estimate reassessed. | | 2026-10-24 | minor | G12: General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is active reaches it, and the radius ↑/↓ set while drawing (or a Tool Options value) becomes the preference, within 0–1296 pt (#394, `crates/engine/src/tests/constrain_tools.rs`); no entry closed, estimate unchanged. | | 2026-10-22 | minor | Documentation reconciled with newer upstream progress docs in six passes (after reviews F-2 and F-3), including a corrected description of the scaleArt follow-up evidence; every dated revision and the original scaleArt runtime evidence kept; runtime, scores and estimates unchanged. | @@ -195,7 +195,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. -- **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-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 CMaps (Unicode, Shift-JIS, EUC, GBK, Big Five, UHC) set as type, vertical over `-V` CMaps) 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. @@ -321,7 +321,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** Divide splits filled paths along open cutting lines and curves (#1307), preserving the frontmost filled appearance, implicit filled closure, holes, unrelated objects and one Undo/Redo; the existing bounded-map fallback remains. -- **Done:** Numeric fields keep a text drag's start when the pointer gains focus and movement exceeds the click threshold (#1326, in part). A completed focus click selects the whole value; keyboard focus, double-click and numeric stepping remain regression-tested. Short movements still classified as clicks can select all; that case remains open. +- **Done:** Numeric fields keep a text drag's start when the pointer gains focus, including short movements that egui still classifies as clicks (#1326). A release more than 1 pt from the press keeps the caret; a tap, including one with a pixel of pen or trackpad jitter, selects the whole value. Keyboard focus, double-click and numeric stepping remain regression-tested (`tests/numfields.rs`, `a_short_drag_released_as_a_click_keeps_its_caret` and `a_sub_threshold_movement_released_as_a_click_keeps_its_caret`). - **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. @@ -337,7 +337,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi selected with Direct Selection (#1839, #1824); Snap to Grid wins over Snap to Pixel (#1840, #1816 unconfirmed); Redo during a drag keeps the drag (#1856, #1719); a template layer dims its images by Dim Images instead of fading the whole layer (#1864, #1683); a failed save or export is reported in a dialog that names the file and stays until - closed, the document left modified (#1865, #1685). + closed, the document left modified (#1865, #1685). Ordinary File > Save now offers Save As for terminal permission/read-only refusals (#6146), unless another picker is open; recovery errors retain the dialog. Regression execution pending; G11 estimate unchanged. - **Done:** the Shape Builder offers the empty areas closed shapes enclose as regions, such as the middle of four circles that touch, and merges them with the regions around them (#1691). - **Done (#2523):** Shape Builder Gap Detection shares Live Paint's spatially indexed gap finder, @@ -384,6 +384,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 +416,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 @@ -592,7 +594,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 +612,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,8 +634,8 @@ 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 (seven original libraries generated in code: Art Ribbons, Calligraphic Nibs, Bristle Brushes (one - bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + its own (eight original libraries generated in code: Art Ribbons, 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 step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and @@ -652,10 +656,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Fine Grain, Canvas Weave, Burlap Sack, Cracked Glaze, Mosaic Floor, Quilted Squares, Leaded Glass and Damp Paper, painted with the texture effects; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the seven shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic +- **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,12 +861,17 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 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. | +| 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 | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | | 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. | -| 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. | | 2026-11-11 | minor | G5: built-in Status Effects graphic style library (Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade, Shield Buff); Missing trimmed; estimate 7–17 → 6–16 h; MCP docs list UI Frames and Map Markers under Symbol libraries. | | 2026-11-11 | minor | G5: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem; no default 9-slice); Missing trimmed; estimate 8–18 → 7–17 h. | +| 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. | | 2026-11-10 | minor | G4 type: Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, #4936; the Check Spelling dialog remains; scores and estimates unchanged. | | 2026-11-10 | minor | Tidy-up, no content change: in Feature submission evidence, a stray table row and two "PR #NNNN revision" notes move into Feature revisions, the three "Feature evidence last updated" lines become one, the duplicate Flyout update note goes (its row stays), and eight reconciliation rows become two. Scores and estimates unchanged. | | 2026-11-10 | minor | G5: built-in Game HUD graphic style library (Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine, Boss Alert); Missing trimmed; estimate 10–20 → 9–19 h. | @@ -890,16 +898,17 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-11-05 | minor | G15: a document's first frame renders on the render worker (#4155); the longest UI-thread stall opening that file went from 8.7 s to 64 ms. Estimate unchanged. | | 2026-11-05 | minor | G15: a reduced image is shrunk before it is separated into ink planes (#4155); first frame 9.2 s → 1.6 s, sharp frame after a pan 3.9 s → 8 ms on that file. Estimate unchanged. | | 2026-11-05 | minor | G7: document tabs are dragged along the tab strip to reorder them (`document.move`, #5663). Estimate unchanged. | +| 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides within the artwork (#5280). Guides outside the artwork remain unsupported. The G5 estimate remains 14–24 h. | | 2026-11-02 | minor | G15: the renderer keeps a compound path's assembled path between frames and drops unused geometry entries after a few frames however few there are. Estimate unchanged. | | 2026-11-02 | minor | G15: hit testing culls paths and compound paths by their control points' box and tests a compound path's outline and fill against only its members near the click; the same objects are hit. Estimate unchanged. | -| 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides within the artwork (#5280). Guides outside the artwork remain unsupported. The G5 estimate remains 14–24 h. | -| 2026-10-31 | minor | G12: Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but not acted on. Estimate unchanged. | | 2026-11-01 | minor | G5: dynamic symbols (#4938): Symbol Options › Symbol Type, per-instance fill, stroke, opacity and blend for objects of a dynamic symbol's art that stay linked (redefining keeps them, Break Link bakes them, Reset and Static drop them; `symbol.instance.setAppearance`, `symbol.instance.reset`; `crates/engine/src/tests/dynamic_symbols.rs`); on-canvas Direct Selection of the parts and `.ai` round trip remain. Estimate 15–26 → 14–24 h. | | 2026-11-01 | minor | G13: Artboard background colors in output (#4937): raster exports (a transparent background too), PDF, SVG and EPS pages, `.ai` files' PDF pages, Export for Screens and print (and its preview) draw each artboard's color behind its art, inside the artboard and in the color's own model; Export for Screens has Include Artboard Fill beside Include Bleed (on by default, remembered; `includeArtboardFill`, also on `document.export` and `file.print`); editing data keeps the color as the artboard's, and Export for Screens' document rows keep it too; estimate unchanged. | | 2026-11-01 | minor | G5: Select › Start Global Edit on a group takes the groups like it (the same objects at the same places, moved or scaled; Appearance, Size and the artboard options apply) and gives an edit made to an object inside one to the object at its place in each of the others, in proportion, as one undo step (#4939); rotated copies and Shift-click exclusion remain. Estimate unchanged. | +| 2026-10-31 | minor | G12: Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but not acted on. Estimate unchanged. | | 2026-10-31 | minor | G4: PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`. Estimate unchanged. | | 2026-10-31 | minor | G13: Direct Selection snaps the end of an existing straight line, dragged by its end anchor, square to straight paths and grazing curves (Smart Guides › Geometric Guides: Snap to Perpendicular and Snap to Tangent), the case the public Snap to Perpendicular and Snap to Tangent pages add to drawing with the Line Segment and Pen tools (#2530). Scores and estimates unchanged. | | 2026-10-31 | minor | G8: a gradient mesh read from a file is budgeted: at most about a million quads per tessellation, and a mesh with more patches than that is malformed (#3592); estimate unchanged. | +| 2026-10-31 | minor | G1: Grid Repeat Options has a Preview checkbox (#2651); estimate unchanged. | | 2026-10-30 | minor | G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | | 2026-10-30 | minor | G1: Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J) moves two open path ends to their midpoint and joins them there, the joined anchor a corner or a smooth point as its dialog's Points choice asks, in one undo step; the "Average and Join in one step" entry is done. Estimate unchanged | | 2026-10-30 | minor | G12: Preferences audit (#394): 5 options are stored but not acted on (Open Documents As Tabs, Units › East Asian Type, Hyphenation › Default Language, Scratch Disks, Legacy vector clipboard), each waiting on a subsystem; Highlight Alternate Glyphs, Real-time Drawing and the Devices options act. Estimate unchanged. | @@ -907,16 +916,19 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-29 | minor | G10: the Linux Flatpak on GNOME stops the clipboard retry storm (retried with a growing wait, logged once). Estimate unchanged. | | 2026-10-29 | minor | G12: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act. No entry closed, estimates unchanged. | | 2026-10-29 | minor | G8: a blend makes at most 100,000 steps in all, the steps of blends nested in its keys and steps included; past it each pair of keys keeps the same share of its steps (#3579). No entry closed, estimates unchanged. | +| 2026-10-28 | minor | Flare ray rotation follows vertical travel at one degree per document point, independent of pointer event count. Shift freezes the current orientation; Space also freezes rotation on the first update. This is the chosen application interaction rate. Scores and estimates unchanged. | +| 2026-10-28 | minor | Drop Shadow Darkness subtracts the normalized amount from RGB source channels or adds it to CMYK/Gray black before profile conversion and blur; near-100% becomes gray black, with shadow opacity separate. SVG keeps live filters for uniform sRGB sources and renders shadows for other RGB profiles, source models and gradients. Gradient-stop adjustment is an application rule; spot/custom colors and composite-source parity remain open. Scores and estimates unchanged. | +| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | +| 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | | 2026-10-26 | minor | G9: EPS save-back: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten. This narrows G9's save-back workflow only; its import-fidelity items (layer and group structure, guides, open speed, live objects) and estimate are unchanged, and writing Illustrator's own editing data stays out of scope (#868) | | 2026-10-26 | minor | G7: View › New View… / Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)): the View menu lists every one of a document's 25 saved views (it listed 10), Edit Views… lists them to rename or delete in one undo step (`view.saved.edit {edits}`), and a file's views are checked when it opens; New View/Edit Views leave G7's missing list; estimate unchanged | | 2026-10-25 | minor | G4: a double-click on a threaded port with the Selection tool breaks the thread there (`text.thread.break {id, port}`): the story flows into the frames before the break, the frames after it are left empty and stay threaded with each other when two or more; a double-click on a port with no thread does nothing; estimate unchanged | -| 2026-10-31 | minor | G1: Grid Repeat Options has a Preview checkbox (#2651); estimate unchanged. | | 2026-10-25 | minor | G1: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); bounding-box handles keep priority where they meet; Radial Repeat handles remain; estimates unchanged. | | 2026-10-25 | minor | Floating panels open in OS windows of their own on the desktop (inside the window on the web and on Wayland); saved layouts from earlier versions convert to the shared docking layout (#4135); scores and estimates unchanged (estimated) | -| 2026-10-24 | minor | Recolor Artwork › Assign: double-clicking a row's new color opens the Color Picker over the dialog; its OK sets the row's new color in the model it was picked in (a CMYK mix stays CMYK, RGB becomes CMYK in a CMYK document), Cancel leaves the row as it was. A Lab new color is kept, but a Lab color can't be typed yet: the picker's Lab fields work in RGB, as for fill and stroke (part of #4028; Lab entry, swatch libraries and the row menu remain). Scores and estimates unchanged. | | 2026-10-25 | minor | G12: Pathfinder panel › Pathfinder Options… (Precision, Remove Redundant Points, Divide and Outline Will Remove Unpainted Artwork), for #3426; no entry closed, estimate unchanged | | 2026-10-25 | minor | G12: Artboards panel › Delete Empty Artboards (`artboard.deleteEmpty`); no entry closed, estimate unchanged | | 2026-10-25 | minor | G12: the Transform panel menu's Transform Object Only / Pattern Only / Both (`transformPanelPatterns`) choose what the Transform panel and the Properties panel's Transform section transform (until a choice, General › Transform Pattern Tiles as before), and Use Registration Point for Symbol (`useRegistrationPointForSymbol`) measures and transforms one selected symbol instance from its registration point; estimate unchanged | +| 2026-10-24 | minor | Recolor Artwork › Assign: double-clicking a row's new color opens the Color Picker over the dialog; its OK sets the row's new color in the model it was picked in (a CMYK mix stays CMYK, RGB becomes CMYK in a CMYK document), Cancel leaves the row as it was. A Lab new color is kept, but a Lab color can't be typed yet: the picker's Lab fields work in RGB, as for fill and stroke (part of #4028; Lab entry, swatch libraries and the row menu remain). Scores and estimates unchanged. | | 2026-10-24 | minor | G1: Live Paint Make, Merge and the Live Paint Bucket refuse type, images and other objects that aren't paths, changing nothing, instead of hiding them among the group's sources; Properties shows Merge Live Paint only for a selection it can take. No entry closed, estimates unchanged. | | 2026-10-24 | minor | G1: Properties: Blend Options and Merge Live Paint in Quick Actions, the Width Profile list and an applied effect's Delete icon in Appearance, and the kind and radius of corners selected with Direct Selection, as the public Properties panel overview lists them. No entry closed, estimates unchanged. | | 2026-10-24 | minor | Docs catch-up: G11's three Missing lines (left by merges, naming fixed bugs) become one: SVG units reverting to points (#864) and a Transform effect in a fill's or stroke's own effect list (#885) remain; Shape Builder regions (#937, closed), PDF text placement (#722), the bezier drag preview (#834) and interior anchor delete are done. G2's pre-#2883 "the Effect Gallery dialog" Missing and 4–6 h estimate lines and a duplicate Done line removed. Estimates unchanged. | @@ -943,17 +955,15 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-23 | minor | G4: OpenType SVG colour fonts draw and export as vector art; colour fonts are done but for images, scripts and text inside SVG glyph documents and palette variables; estimate unchanged. | | 2026-10-23 | minor | G12: General › Constrain Angle acts for the arrow keys, Shift drags (selection tools, Free Transform, slices, Reshape, Measure, Pen handles) and new rectangles, ellipses, grids, type, polygons, stars and size-dialog shapes (#394); no entry closed, estimate unchanged | | 2026-10-23 | minor | G4: Glyph Scaling leaves hung punctuation at its width and Standard burasagari counts a hung line start as room (`crates/text/src/tests/hang.rs`). No entry closed, estimates unchanged. | -| 2026-10-20 | minor | G4: a paragraph's Auto Leading also drives the Character panel's Leading, the leading nudge and Fill with Placeholder Text; estimate unchanged | -| 2026-10-22 | minor | G11: #834 done (a tool drag's preview could miss the position where the pointer came back into the window and the move in the release's frame, so the release committed a stale handle or anchor); estimate unchanged | | 2026-10-23 | minor | G1: a path moved by a press on its stroke or beside its edge (Selection, Group Selection, Direct Selection), and a straight segment Direct Selection drags, snap by the nearest point of the path, so the path lands exactly on a ruler guide with "align" instead of the pressed spot landing there and the path a few pixels beside it (#3701). No entry closed, estimates unchanged. | -| 2026-10-22 | minor | G11: the Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects, its checkboxes visible (#885); canvas, export and Expand Appearance agree. A Transform in a fill's or stroke's own effects ignores the two options. Estimate unchanged. | | 2026-10-23 | minor | G4: COLR colour fonts (v0 and v1) draw and export as vector art; paints documents lack (sweep gradients, Porter-Duff compositing, radial gradients from a circle, repeating gradients) draw the glyph as an image; OpenType SVG fonts remain; estimate unchanged. | -| 2026-10-18 | minor | G11: Shape Builder on art of thousands of segments (traced outlines, type outlines): lines cut it again past 4,096 segments, where the planar map used to give up and the tool offered whole shapes (#937's reproduced case; the planar map's edge-source and face lookups were quadratic in the segments, now indexed, and the cap is 65,536); estimate unchanged until confirmed on the reporter's file | | 2026-10-23 | minor | Fix #3705 in part: drawing modes apply to pastes (Draw Inside puts Paste, Paste in Place and Paste on All Artboards inside the object drawn into; Draw Behind puts Paste in Place and Paste on All Artboards behind the selection), and Shift+D goes from Draw Behind back to Draw Normal when Draw Inside can't draw into the selection instead of stopping at Draw Behind; scores and estimates unchanged. | | 2026-10-23 | minor | G7: Artboard Options' Show Video Safe Areas (action-safe 3.5% and title-safe 5% in from each edge, EBU R 95, canvas only) and Video Ruler Pixel Aspect Ratio (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio`), and View › Rulers › Show Video Rulers (`view.videoRulers`), which counts the active artboard in those pixels (#2849); video rulers leave G7's missing list; estimates unchanged. | | 2026-10-23 | minor | Artboard Options: Show Center Mark and Show Cross Hairs (`artboard.setProps`/`artboard.new` `showCenterMark`, `showCrossHairs`) draw a center cross and lines from the middle of each side through the center on the canvas only, in one undo step; video safe areas, video rulers and the pixel aspect ratio remain missing; scores and estimates unchanged. | | 2026-10-23 | minor | G1: Simplify of part of a path: with anchors or segments of a path direct-selected, Object › Path › Simplify (slider, Auto-Simplify and dialog) refits only each run of selected segments between its end anchors and leaves the rest of the path exactly as it was; estimates unchanged | | 2026-10-23 | minor | G11: Simplify: Object › Path › Simplify opens its slider at the Auto-Simplify level for the selection, previewing that result, instead of at 1 pt; partial-path simplify remains missing; estimates unchanged | +| 2026-10-22 | minor | G11: #834 done (a tool drag's preview could miss the position where the pointer came back into the window and the move in the release's frame, so the release committed a stale handle or anchor); estimate unchanged | +| 2026-10-22 | minor | G11: the Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects, its checkboxes visible (#885); canvas, export and Expand Appearance agree. A Transform in a fill's or stroke's own effects ignores the two options. Estimate unchanged. | | 2026-10-22 | minor | G8: stacked geometry effects make at most 2 million anchors of a path and about 8 million lines to draw: Roughen, Zig Zag, Free Distort, Twist, the warps and Transform build within it, and an effect that would still pass it is left out (#3578). No entry closed, estimates unchanged. | | 2026-10-22 | minor | G13: gradient Method (30.0), Classic or Perceptual, in the Gradient panel, on the canvas, in raster exports and as images in SVG and PDF exports (with Dither, a Perceptual fill takes its noise); G13 estimate 14–24 h → 13–23 h. | | 2026-10-22 | minor | G8: SVG images in SVG images share the depth cap (any data URL type usvg reads as SVG) and SVGZ images one 512 MB unpacking cap; past either, an image is left out with a warning (#3562, #3563). No entry closed, estimates unchanged. | @@ -971,6 +981,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-21 | minor | Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top. No entry closed, estimates unchanged. | +| 2026-10-20 | minor | G4: a paragraph's Auto Leading also drives the Character panel's Leading, the leading nudge and Fill with Placeholder Text; estimate unchanged | | 2026-10-20 | minor | G1: for the selection tools, a press within the selection tolerance of a clipping path's outline (a clip group's, or a layer's clipping mask) hits the clipping path before the art it clips, so Direct Selection takes its segments and the Selection tool the clip group even where the art lies over the mask's edge; Select Behind lists the same; the eyedropper and the tools that act on what is drawn are unchanged. Doc and tool regression tests. No entry closed, estimates unchanged. | | 2026-10-20 | minor | Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; existing opening gestures retained; scores and hours unchanged. | | 2026-10-20 | minor | Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; scores and hours unchanged. | @@ -984,14 +995,16 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-19 | minor | Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Regression coverage; scores and estimates unchanged. | | 2026-10-19 | minor | Cached canvas reprojection preserves scale during resize, rotation and DPI changes. Contributor original-file acceptance confirmed; scores and estimates unchanged. | | 2026-10-19 | minor | Color Picker layout (#2008): compact controls, centered header, stacked actions and aligned inputs; screen-reader channel values, current shared button names and styling, screen sampler and model-by-model Tab order retained. Narrow windows scroll vertically. Documentation correction: #1692 was completed upstream before #2008; the Direct Selection alignment gap remains open. Scores and hours unchanged. | +| 2026-10-19 | minor | G13: blend easing (#1124): Ease In / Ease Out and separate Color Ease In / Out in Blend Options, saved with the blend; on-canvas handles and easing in the Blend panel remain. Estimate unchanged | +| 2026-10-19 | minor | G13: tangent and perpendicular snapping for lines drawn from an anchor (Pen, Curvature, Line Segment, with Smart Guides on), View › Snapping with every snap option, and the `view.snapping` command (#2530); estimate unchanged | +| 2026-10-18 | minor | G11: Shape Builder on art of thousands of segments (traced outlines, type outlines): lines cut it again past 4,096 segments, where the planar map used to give up and the tool offered whole shapes (#937's reproduced case; the planar map's edge-source and face lookups were quadratic in the segments, now indexed, and the cap is 65,536); estimate unchanged until confirmed on the reporter's file | | 2026-10-18 | minor | G11: Shape Builder Gap Detection (#2523): Shape Builder Tool Options with a Gap Length of Small, Medium, Large or Custom bridges small gaps between open paths for the regions they bound; the other Shape Builder options stay missing. The stale gap line on deleting an interior anchor of an open path is removed (the behaviour was already implemented). Estimates unchanged | | 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. | | 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them; a Top-handle regression confirms scaling of the default repeat. Center and end handles retain their move and rotation behavior. G1 scope and estimate unchanged. | -| 2026-10-19 | minor | G13: blend easing (#1124): Ease In / Ease Out and separate Color Ease In / Out in Blend Options, saved with the blend; on-canvas handles and easing in the Blend panel remain. Estimate unchanged | -| 2026-10-19 | minor | G13: tangent and perpendicular snapping for lines drawn from an anchor (Pen, Curvature, Line Segment, with Smart Guides on), View › Snapping with every snap option, and the `view.snapping` command (#2530); estimate unchanged | | 2026-10-17 | minor | G11: #722 fixed (PDF text slid on open where the file spaced its glyphs other than the font's metrics: kerning, word and character spacing, `TJ` adjustments, text rise, narrow vertical glyphs; justified text boxes stayed lines); estimate unchanged | | 2026-10-17 | minor | Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged. Windows native canvas checks and screenshots reviewed in PR #2643. | | 2026-10-17 | minor | G12: Background Export progress dialog with Cancel and Continue in Background (#2564); no entry closed, estimates unchanged | +| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. | | 2026-10-16 | minor | Native documents restore transparency grid visibility (#1968); save/open regression covers on/off, old files and SVG; scores and hours unchanged (estimated) | | 2026-10-16 | minor | G6: MCP cancellation, progress notifications and a busy answer during long exports (#2165, #2171, #2172); no entry closed, estimates unchanged | | 2026-10-16 | minor | G11: PDF export of a CMYK document reports its RGB colours and non-CMYK images, with a count, in the export warnings (#2162); no entry closed, estimates unchanged | @@ -1002,31 +1015,30 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-16 | minor | G2: Effect › Sketch › Bas Relief, Charcoal, Photocopy and Stamp (#2527); measured Photoshop filters 37/57 with Cutout, Neon Glow and Poster Edges, 20 remaining (18–30 h). | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | | 2026-10-16 | minor | G1: Smart Guides line extension for a dragged end of a straight segment, at any angle (#2120); estimate unchanged | -| 2026-10-15 | minor | G1: no busy cursor during long operations, listed; estimate unchanged | -| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. | | 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged | | 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged | +| 2026-10-16 | minor | G13: Object › Create Gradient Mesh on an embedded image (#1676); a linked image is refused. No entry closed, estimates unchanged | +| 2026-10-15 | minor | G1: no busy cursor during long operations, listed; estimate unchanged | +| 2026-10-15 | minor | G13: Object › Intertwine (#1763): Make, Edit, Release, the Intertwine tool (hover, click, drawn path) and `object.intertwine.arrange` (toggle, front, back). Bring Forward / Send Backward stay missing; adding objects through the Layers panel is untested. No entry closed, estimates unchanged | +| 2026-10-15 | minor | G11: Transform Each Reflect X flips left-right and Reflect Y top-bottom (#1910). Estimates unchanged. | +| 2026-10-15 | minor | Artboard tool: closing a clean active tab restores the displayed board before Properties Delete. Nine targeted checks passed; three original close-route captures reviewed; scores and hours unchanged. | | 2026-10-14 | minor | G16: Brazilian Portuguese (`pt-br`) catalog complete under the tests (#1914); three key languages remain (Hindi, Arabic, Indonesian), 55–90 → 50–85 h | | 2026-10-14 | minor | G6: dataset CSV import/export (`dataset.import` / `dataset.export`); remaining: scripting surface, Batch, graph variables; estimate 6–12 → 4–8 h | | 2026-10-14 | minor | G6: image variables (`VariableKind::Image`, `variable.makeImageDynamic`, path values on `dataset.select` keeping bounds); remaining: scripting surface, Batch, graph variables, dataset import/export; estimate 8–14 → 6–12 h | | 2026-10-14 | minor | Effect > Warp: style, orientation and percent controls; focused checks passed and original captures reviewed; scores and hours unchanged | | 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. | -| 2026-10-16 | minor | G13: Object › Create Gradient Mesh on an embedded image (#1676); a linked image is refused. No entry closed, estimates unchanged | -| 2026-10-15 | minor | G13: Object › Intertwine (#1763): Make, Edit, Release, the Intertwine tool (hover, click, drawn path) and `object.intertwine.arrange` (toggle, front, back). Bring Forward / Send Backward stay missing; adding objects through the Layers panel is untested. No entry closed, estimates unchanged | | 2026-10-14 | minor | MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged. | | 2026-10-14 | minor | Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. | | 2026-10-14 | minor | G1: Curvature curve geometry (the Curvature tool's curve: 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°), and the ends of an open path curve in, so uneven spacing neither overshoots nor loops; #1236, in part); neighbour re-curving on existing paths remains open; estimates unchanged | -| 2026-10-15 | minor | G11: Transform Each Reflect X flips left-right and Reflect Y top-bottom (#1910). Estimates unchanged. | | 2026-10-14 | minor | Quick Image Trace presets reveal the adjustment panel with Advanced open; Properties reopens it. Sliders keep their preset row until release so the re-trace is committed. UI regressions added; scores and remaining estimates unchanged. | | 2026-10-14 | minor | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding | | 2026-10-14 | minor | No entry closed. G8 and G11 record the later merges of 2026-10-14 (#1852, #1862–#1865, #1871–#1873, #1839, #1840, #1856); #1683 (Dim Images) and #1685 (failed-save dialog) are done by #1864 and #1865. Gaps noted for reporters: G1 Effect › Warp through Warp Options (#1760), Color Picker Tab order (#1692), aligning to a key anchor (#1636; the public Align page does not say how the key anchor is chosen); G7 custom toolbars (#1759), workspace panel widths (#1753); G11 Shape Builder regions between closed shapes (#1691), deleting an interior anchor of an open path; G13 Intertwine (#1763), gradient mesh from an embedded image (#1676), Image Trace radial gradients (draft #1849). Average and Join with a corner or smooth choice was already listed (G1). G12: a line two merges had left twice is there once, without Appearance of Black for output, which acts since #1750. Estimates unchanged | | 2026-10-14 | minor | Existing-guide Escape cancellation: eight app-input regressions, the Direct Selection Escape control and current full CI passed; scores and hours unchanged. | -| 2026-10-15 | minor | Artboard tool: closing a clean active tab restores the displayed board before Properties Delete. Nine targeted checks passed; three original close-route captures reviewed; scores and hours unchanged. | | 2026-10-14 | minor | Artboard tab changes align the tool with the displayed board; same-tab multiselection and Move/Scale Artwork options preserved. Three tab regressions and historical full CI on `a0671777` passed; scores and hours unchanged. | | 2026-10-14 | minor | Width-point finite-arithmetic follow-up: atomicity, history and native round-trip regressions and current full CI passed; scores and hours unchanged. | +| 2026-10-14 | minor | No entry closed. G11: Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing); Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674); In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666); Escape cancels a Direct Selection drag (#1803, #1743); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); A filled Pencil path keeps a stroke of None (#1804, part of #1679); In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635); Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756); Select › Same matches type on its characters' fill and stroke (#1746, #1737); Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). G8: WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780); One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779); MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727); MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). G10, unconfirmed: Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). A clause repeated in two G11 bullets is there once. Estimates unchanged | | 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | | 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | -| 2026-10-14 | minor | No entry closed. G11: Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing); Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674); In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666); Escape cancels a Direct Selection drag (#1803, #1743); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); A filled Pencil path keeps a stroke of None (#1804, part of #1679); In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635); Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756); Select › Same matches type on its characters' fill and stroke (#1746, #1737); Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). G8: WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780); One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779); MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727); MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). G10, unconfirmed: Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). A clause repeated in two G11 bullets is there once. Estimates unchanged | | 2026-10-13 | minor | No entry closed. Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570); Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578); Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590); A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613); Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615); Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616); Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620); Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't); estimates unchanged | | 2026-10-13 | minor | G1/M11: Artboard panel deletion, protected deletion keys and navigator/tool synchronization before and after deletion; history normalization preserves valid multiselection; history regressions and full workspace CI pass; scores and hours unchanged | | 2026-10-13 | minor | G4: mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; other mixed paragraph fields and estimates unchanged, no gap entry closed | @@ -1034,9 +1046,9 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. | | 2026-10-13 | minor | G1: Average and Join in one step with a corner or smooth choice is listed as missing (the ui-parity row said done; the command does not exist); Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). G11: Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); a round corner next to a curved side curves smoothly instead of sticking out of the shape and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512); Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). G9: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); 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); Polish catalog (#1498); estimates unchanged, no gap entry closed | | 2026-10-13 | minor | Interaction: Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1508), sliders in dialogs show their rail (#1483), a template layer stays in Preview in Outline view (#1486), a tab stop in the Tabs panel drags from where the press landed (#1458); type: commands and MCP `add_text` report area type that does not fit its frame (#1471); 3D and Materials on outlined type and other shapes of many small curves (#1481), Twist 0° to 360° (#1524), twisted walls keep their faces (#1525); print-size images draw, export and crop (#1517); `vectorcraft-cli perf` reports load and memory on Windows (#1502; the idle-machine run is still missing); French catalog complete again (#1511); `graph.setData`'s CSV keeps each value exactly (#1485); estimates unchanged, no gap entry closed | +| 2026-10-13 | minor | G1: Split Into Grid has its documented options (#1462), Transform Objects in the Transform dialogs and Transform Each and Reflect across an angle (#1448, #1460), the Pathfinder panel works on a group selected alone (#1450; one plain path alone is not settled), a focused field follows its value (#1457), selected text in value fields is readable (#1465 in part); G9: grayscale TIFFs with an alpha channel or extra channels place (#1466 in part), strokes kept apart from a faded or masked fill on PDF and `.ai` import (#1434); G4: Fit Headline in a frame one line tall (#1444), frames beyond a billion points hold no text (#1451); G6: commands refuse a parameter key they don't take (#1442); G10: a window surface past 8192 px no longer closes the app (#1459 in part); G15: edits and Alt-drags of thousands of objects (#1429 in part); G16: 128 more Czech strings (#1461); estimates unchanged | | 2026-10-12 | minor | Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged | | 2026-10-12 | minor | G9: Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification (#868): layers, groups, compound paths, clipping groups, type as text objects (or outlines), gradients and images, one artboard per file; hidden objects in a hidden layer, omitted or shown; transparent images flattened or omitted; legacy type read on open; it waits for users' testing, so the estimate is unchanged | -| 2026-10-13 | minor | G1: Split Into Grid has its documented options (#1462), Transform Objects in the Transform dialogs and Transform Each and Reflect across an angle (#1448, #1460), the Pathfinder panel works on a group selected alone (#1450; one plain path alone is not settled), a focused field follows its value (#1457), selected text in value fields is readable (#1465 in part); G9: grayscale TIFFs with an alpha channel or extra channels place (#1466 in part), strokes kept apart from a faded or masked fill on PDF and `.ai` import (#1434); G4: Fit Headline in a frame one line tall (#1444), frames beyond a billion points hold no text (#1451); G6: commands refuse a parameter key they don't take (#1442); G10: a window surface past 8192 px no longer closes the app (#1459 in part); G15: edits and Alt-drags of thousands of objects (#1429 in part); G16: 128 more Czech strings (#1461); estimates unchanged | | 2026-10-12 | minor | TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged | | 2026-10-12 | minor | Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged | | 2026-10-12 | minor | G3: Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407); materials, lights, shadows, Classic, Turntable and export remain, 45–75 → 25–45 h | @@ -1055,7 +1067,6 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-12 | minor | DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves stay open and curves retained; rational/non-rational import and export versions regression-tested; scores/hours unchanged | | 2026-10-12 | minor | G16: Vietnamese catalog added (complete under the tests); three key languages remain (Hindi, Arabic, Indonesian), 55–90 h | | 2026-10-12 | minor | G16: Korean catalog added (complete under the tests); four key languages remain, 60–100 h | -| 2026-10-11 | minor | Czech render-site follow-up: file-dialog filters, diagnostics, preset numbering, contributor tooltips/plurals, gradient grammar and contextual printer mark styles; estimates unchanged | | 2026-10-12 | minor | Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged | | 2026-10-12 | minor | Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged | | 2026-10-12 | minor | G11: clear stale component refs after Simplify/Add Anchor Points; preview and point counts give visible feedback (#1272); estimates unchanged | @@ -1071,6 +1082,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-12 | minor | G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged | | 2026-10-12 | minor | G11: Divide uses open cutting paths without taking their paint (#1307); implicit filled closure, holes and history regression-tested; estimates unchanged | | 2026-10-12 | minor | G11: numeric fields keep the text-drag start instead of replacing it with select-all on press (#1326, in part); keyboard and click selection regression-tested; estimates unchanged; short movements below the click threshold remain open | +| 2026-10-11 | minor | Czech render-site follow-up: file-dialog filters, diagnostics, preset numbering, contributor tooltips/plurals, gradient grammar and contextual printer mark styles; estimates unchanged | | 2026-10-11 | minor | G15: large-selection edits, cached live effects and faster Pathfinder (#1173); a 25k-path perf run meets every budget; estimate unchanged | | 2026-10-11 | minor | G4: Middle Eastern digit types done (Character panel menu › Digits); estimate unchanged | | 2026-10-11 | minor | G4: proportional vertical metrics (#966) done; estimate unchanged | @@ -1082,10 +1094,6 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-10 | minor | G9 evidence counted from the tracker (28 `.ai`/EPS issues, 20 fixed); impact narrowed to handing files back | | 2026-10-10 | minor | Alpha blockers checked against the core-workflow gate: none | | 2026-10-10 | major | Moved from the ROADMAP's "Where we're lacking"; kept ids G1–G8, added G9–G19 (file formats, stability, bugs, preferences, 30.x additions, pen hardware, performance, localization, ecosystem, AI, accessibility) and re-ranked | -| 2026-10-28 | minor | Flare ray rotation follows vertical travel at one degree per document point, independent of pointer event count. Shift freezes the current orientation; Space also freezes rotation on the first update. This is the chosen application interaction rate. Scores and estimates unchanged. | -| 2026-10-28 | minor | Drop Shadow Darkness subtracts the normalized amount from RGB source channels or adds it to CMYK/Gray black before profile conversion and blur; near-100% becomes gray black, with shadow opacity separate. SVG keeps live filters for uniform sRGB sources and renders shadows for other RGB profiles, source models and gradients. Gradient-stop adjustment is an application rule; spot/custom colors and composite-source parity remain open. Scores and estimates unchanged. | -| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | -| 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | > **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, 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-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 e853cbe965..a397a19e22 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,5 +1,7 @@ # VectorCraft MCP server +> **Last updated:** 2026-11-11 · **Change:** Save location-refusal error (#6146); existing response shapes retained. + `vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio (newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to draw, inspect and look at VectorCraft documents. @@ -129,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 @@ -165,11 +169,30 @@ the suite) keep the agent to the folders you give it: and `modified` the document keeps of that file. Each row keeps its id, `name` (`fileName`), status and other fields. This applies to every document, also one the person or a client with wider roots opened. The paths a client gives `links.relink` and `links.unembed` stay in their replies, and the document's links are not changed. +- `image.info` gives `link: null` for a linked file outside the read roots, as `links.info` leaves it out; its id, + name, `linked` and the other fields stay. `links.findFiles` and `text.findFontFiles` replies, including later + `{}` and `{stop: true}` calls on a search another client started, leave out the files found outside the read + roots (with their `size` and `match`), a sought link's `path` outside them, and `folder` when the folder searched + is outside them; `error` then gives the reason without the path (for example `the folder can't be read`). The + rows keep their `name`, `ids`, family, style and status, and the counts stay. `text.addFontFiles` gives + `folder: null` when VectorCraft's Fonts folder is outside the read roots, and only the file name in `copied` and + `kept` `to` and in `skipped` `file` for a path outside them; the fonts are still copied. - `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show a folder preference outside the read roots (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`) as `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. +- `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` + (also in `plugin.info`) are named by their file name, in the errors too. A plug-in turned off for the next + launch (`disabled`, and `pendingItem` in `plugin.safeMode`) has a file outside the read roots named by its file + name. `plugin.setEnabled {file}` takes that name, and a plug-in's `source` name, when no other listed file has + it. In `file.recovery.list` and `file.recovery.restore`, a + document's original file in `path` is `null`, and so is the recovery folder in `location` (`file.recovery.save` + too); the restored documents keep their files. In `scriptReport`, a file the run wrote (`written`) and the + included file a failed run stopped in (`file`, also in `message` and the run's error lines in `console`) are named + by their file name; what the script wrote itself (`log` and its output lines) stays as written. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice @@ -514,6 +537,19 @@ opens the picker through `run_command` with the remote backend. `ui.colorPanel { the panel menu's Show Recent Colors, which hides the Color panel's Recent Colors row, and `true` shows it again; applied colors still join the list while it is hidden. +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`). | @@ -533,7 +569,7 @@ applied colors still join the list while it is hidden. | `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`. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin.<id>"` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | -| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `<name>_<artboard>.eps` beside it, listed in the reply's `files`). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | +| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `<name>_<artboard>.eps` beside it, listed in the reply's `files`). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. Location refusals return `READ_ONLY_LOCATION: <reason>` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `hpgl` (a `.plt` plot file for plotters and cutters), `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF, WMF and HP-GL write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | @@ -794,7 +830,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`. @@ -1143,6 +1179,41 @@ 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. PDF import finds a sharp step between two colors in an axial or radial shading, in any PDF, 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, with at +most 16,384 samples a shading) instead of blurred across the samples around 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: + +```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): @@ -2307,9 +2378,12 @@ everything in it, and `select.all`, `select.allOnArtboard` and `select.inverse` `brush.list`), whatever the brush is named; a brush of another type with "Bristle" in its name does not count. `select.object.notAlignedToPixelGrid` (Select › Object › Not Aligned to Pixel Grid) selects the paths and compound paths whose pixel-aligned property (`object.setPixelAligned`) is off and returns `{count}`. It goes by the property, -not by where the anchors are: a path on whole points with the property off is selected. Guide paths, type, images, -groups and other objects are not, nor are the paths inside a compound path, which takes the property as one object. -It looks into groups, replaces the selection, and with no such object clears it; it records no undo step. +not by where the anchors are: a path on whole points with the property off is selected. Guide paths, type (point, +area and on a path), images, symbol instances, gradient meshes, placed documents, groups and other objects are not, +nor are the paths inside a compound path, which takes the property as one object. A path with effects is selected +as the path it is. It looks into groups, clip groups, blends, envelopes, repeats and Intertwine groups and selects the +paths they hold, not the objects that hold them. It replaces the selection, and with no such object clears it; it +records no undo step. `select.nextAbove` and `select.nextBelow` select the nearest such object above or below the first selected object among the objects beside it in its group or layer, passing over hidden and locked objects and sublayers. When there is no such object, the selection stays as it is. @@ -2392,7 +2466,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 @@ -3450,6 +3526,16 @@ that shape, and the effects before it are left out. Its Contents row stands for vector exports write those items as paths, and `effect.expandAppearance` turns them into objects in a group with the image. Use Preview Bounds measures an image by its frame. +A Warp effect (`warp.arc` … `warp.twist`) on an image, or on a group that holds one, bends the image's pixels with its +frame: after the last effect in the list, the image is cut into 10 × 10 cells of two triangles, each a clipped piece of +the image under its own affine map. The cell count is VectorCraft's own. Effects after the Warp reshape only the frame, +a second Warp bends the same pieces, and a Transform effect moves them. A placed gradient on a group that holds the +image follows the group's bounds as the pieces draw them. The pieces keep the +image's fill color when it colorizes a grayscale image, and so do the pieces an envelope cuts. Under any geometry +effect, an image keeps that color and is drawn with or without smoothing as the image is. SVG writes the image once and +draws the pieces with `<use>`; PDF, EPS, `.ai` and PNG write the bent pieces. Removing the warp shows the image as it +was. The images stay unbent when their pieces would make the art pass 2 million objects, and an image stays unbent when +its clipping frame keeps its shape because the warp would pass the effects' anchor budget. ```json {"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}} @@ -3467,9 +3553,10 @@ in the `swatch.list` shape (`library` is an id or a name). `swatch.library.add { into the document as one undo step: a colour group's name brings the whole group, a swatch's name the swatch alone, no names the whole library; swatches the document already has (same name and paint) are reported under `existing` and not added again. `apply: "fill"|"stroke"` also applies the first one, in the same undo step (what clicking a -swatch in the library panel does). The pattern libraries Geometric Patterns (`geometric-patterns`) and Grit Textures -(`grit-textures`: seamless black grain, grit, sand, speckle, stipple, scratch and fleck tiles), category `patterns`, -hold pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does +swatch in the library panel does). The pattern libraries Geometric Patterns (`geometric-patterns`), Grit Textures +(`grit-textures`: seamless black grain, grit, sand, speckle, stipple, scratch and fleck tiles) and Optical Effects +(`optical-effects`: VectorCraft's own black wave, ring, warped-check, pinched-stripe, chevron and lens tiles), +category `patterns`, hold pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does (the same pattern the document has is reused, and a name another pattern or swatch has gets a number). `swatch.resetDefaults {replace?}` brings back the missing default swatches. @@ -3576,8 +3663,9 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: seven generated in code (Art Ribbons, Calligraphic Nibs, Bristle -Brushes with one bristle brush for each tip shape, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User +Brush libraries are read-only sets of brushes: eight generated in code (Art Ribbons, 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 the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). @@ -4785,6 +4873,30 @@ 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. + 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 @@ -5016,7 +5128,8 @@ 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 @@ -5385,7 +5498,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 @@ -5774,13 +5891,23 @@ They also write the `copyrightNotice` as `dc:rights`, the `copyrightUrl` as `xmp `copyrightStatus` as `xmpRights:Marked` (`True` for copyrighted, `False` for public domain, none for unknown) to the XMP packet. A PDF, `.ai` or `.ait` opened from its pages or its editing copy (`document.open`, -`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `author`, -`description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` that its XMP -packet (`dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, `dc:rights`, -`xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary (`Author`, -`Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give one. A value that -`file.info` would refuse is left out, and the title is set as for any other open (the file name, or a new untitled -name for a template). +`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `title`, +`author`, `description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` +that its XMP packet (`dc:title`, `dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, +`dc:rights`, `xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary +(`Title`, `Author`, `Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give +one (for `dc:title` and `dc:description`, the `x-default` language item first). An SVG opens with the `title` of its +root `<title>` element (not a shape's or group's). A title is read with its white space made single spaces and +without control characters, at most 65,536 characters, as `file.info` takes one. A value that `file.info` would +refuse is left out. A file that gives no title, a blank one or the `Untitled-N` name New gives leaves File Info with +the document's name as its title, and a template opens with a new untitled name. +The title a file gave is File Info's alone: the document's name, which is what its tab shows until it has a file and +what Save As and the default export names start from, stays the file's name with its extension (`box.svg`), or the +title a native file holds. File Info's `title` is the one the file gave, else the document's name. It is saved with the +document (a native file, and the editing data of an SVG, PDF, `.ai` or EPS that carries it), and the files an export +writes carry it (PDF `Title` and XMP `dc:title`, SVG `<title>` and Dublin Core, PNG text, EPS and `.ai` `%%Title`), +where the document has one. Changing the `title` with `file.info` sets the document's name, as it always did, and drops +the title the file gave. `document.inspect` (`inspect_document`) and the `document.open` (`open_file`) reply report the File Info title as `fileInfoTitle`. Their `title` is the document's tab name, which comes from the file name once the document is saved to or opened from a file (with ` [Converted]` or ` [Recovered]` where that applies). @@ -6189,7 +6316,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. @@ -6845,7 +6978,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, @@ -7091,8 +7224,8 @@ chosen artboards draw all of them. A PDF leaves out the layers that don't print ### Dynamic Text: Block `type.dynamicText {ids?|id?, on?: bool, wordGap?, lineGap?}` (Type › Dynamic Text, and the Contextual Task Bar for -area type outside a thread) turns Block on or off for the -selected area type, as it turns Dynamic Text on or off for type on a path ([Dynamic Text](#dynamic-text)). Area Type +area type outside a thread and for point type) turns Block on or off for the +selected area type, and turning it on makes point type area type with Block on (below), as it turns Dynamic Text on or off for type on a path ([Dynamic Text](#dynamic-text)). Area Type Options › Block is the same setting, and the Properties panel's Paragraph section has a Dynamic Text checkbox, which shows Word Gap and Line Gap while it is on. The [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html) describes Block as text that scales to fill a rectangular box, with settings for the spacing between words and between @@ -7106,7 +7239,7 @@ lines. The rest are VectorCraft's own rules: - The layout sets every paragraph with `wordGap` as its Desired word spacing (Justification…), and its Minimum or Maximum word spacing moves out to `wordGap` when `wordGap` lies outside it. The paragraphs keep their own Justification… values, which apply again when Block is off. Each character's leading is its size plus `lineGap`. A - gap given with no area type in the selection is an error. + gap given with no area type in the selection, and no point type that it turns into area type, is an error. - The sizes are written into the characters, so every export and the Character panel see them. After every edit to the text or the frame (typing, `text.editRange`, character and paragraph changes, `text.areaOptions {width}`, a frame resize) the lines are fitted again, in the edit's own undo step. The lines are then measured again, in at @@ -7115,14 +7248,27 @@ lines. The rest are VectorCraft's own rules: - An empty line, or a line of spaces, keeps its size. At 1,296 pt a line can be narrower than the frame; at 0.1 pt it can still wrap. A frame with no room along its lines changes nothing. Right-to-left lines are fitted as left-to-right ones are. -- Off keeps the sizes the text was drawn at. On replaces Auto Size and Shrink Text to Fit. Point type in the selection - is left as it is. Area type in a thread is refused with an error that names it (`text.areaOptions {fit: "block"}` +- Off keeps the sizes the text was drawn at. On replaces Auto Size and Shrink Text to Fit. Area type in a thread is refused with an error that names it (`text.areaOptions {fit: "block"}` refuses it too); threading a frame turns Block off, keeping its sizes. Without `on`, it toggles from the first object's setting, or turns on when a gap is given. A value that is not a boolean, or a gap outside its range, is an error. When nothing changes, no undo step is recorded. - Saved and opened, the native file keeps Block on with its gaps and the sizes in the characters; with Block off it keeps the sizes. +Turning it on also takes point type, in the same undo step (the Contextual Task Bar offers it beside Convert To Area +Type). [Adjust formatting and resize text](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html) +applies Dynamic Text, Block among its shapes, to existing text. VectorCraft's own rules for point type: + +- It becomes area type with Block on and the default Area Type Options, keeping only its gaps: options it kept from + an earlier frame (columns, inset, vertical alignment, first baseline) do not carry over. Along its lines, the frame + is as wide as the widest line as typed with its paragraph's indents, so that line keeps its size and stays where it + was across the page by its own alignment; the other lines are set to that width. When negative indents take more + than the widest line's width, the frame is as wide as that line, and Block makes it larger. The frame starts at the + top of the text and then grows down (to the left for vertical type) to hold every fitted line, around any Text Wrap + objects above it, so nothing overflows. +- The gaps given apply to its frame. Point type with no line to fit (empty, or only spaces and breaks) is left as it + is, and so is point type when `on` is false. Without `on`, point type counts as off. + The reply is `{on, ids, fitScale, wordGap, lineGap}` (`fitScale` as for type on a path, 1 when the first object is area type; the gaps are the first area type's). @@ -7887,20 +8033,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. @@ -7909,6 +8058,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 @@ -7945,6 +8124,26 @@ begins or ends, dragging the centre bracket slides the type along its path and, {"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}} ``` +`type.pathCenter.top {ids?|id?}` and `type.pathCenter.bottom {ids?|id?}` (Type › Type on a Path › Center on Top and +Center on Bottom, and the Contextual Task Bar for type on a closed path) slide the selected type on a closed path along +it so that the middle of the span between its brackets lands on the path's topmost or bottommost point. This is +VectorCraft's own behavior. The path is measured as the object is drawn, after its transform, so a rotated, scaled or +skewed circle centers on its drawn top. On a level stretch, such as a rectangle's top edge, the middle of the stretch +is used; with several separate topmost points, the middle of the shortest part of the path that holds them all is +used. The span keeps its length, and type that runs once round stays once round. The first paragraph's alignment +becomes Center unless it is Center or Justify Center, so the type is centered there. The type is not flipped: on the +bottom of a circle, whose path runs clockwise, it reads upside down outside the circle until `type.pathOptions {flip: +true}` turns it inside. Point type and area type in the selection are left as they are. Type on an open or compound +path, type in a thread, and type on a path with no length or no height are refused with an error that names them, +and nothing changes. The menu items are enabled, and the task bar shows them, only while every selected type on a +path is on one closed path and none of it is in a thread. Several objects move in one undo step. When every object is +already centered there, nothing changes and no undo step is recorded. Each replies `{ids, start, end}` with the first +object's brackets. + +```json +{"name":"run_command","arguments":{"command":"type.pathCenter.top","params":{}}} +``` + ## Dynamic Text `type.dynamicText {on?: bool, ids?}` (Type › Dynamic Text, and the Contextual Task Bar for type on a path) turns @@ -7958,8 +8157,8 @@ and tate-chu-yoko offsets by one factor, so runs keep their proportions; Type on scaled. The first character's size, in document points, moves in steps of 0.1 pt within 0.1 to 1,296 pt; text that still runs past the end bracket at 0.1 pt overflows. Reshaping or scaling the path fits the text again. The stored sizes do not change: turning Dynamic Text off writes the sizes the text was drawn at into its characters, so it keeps -its look, in one undo step. Point type in the selection is left as it is, and area type in it takes Block -([Dynamic Text: Block](#dynamic-text-block)). Type on a path in a +its look, in one undo step. Area type in the selection takes Block, and turning it on makes point type in the +selection area type with Block on ([Dynamic Text: Block](#dynamic-text-block)). Type on a path in a thread is refused with an error that names it, and the menu item and the task bar item are not offered for it; `text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and list them in `dynamicTextOff`; `addon.type.releasePath` turns it off at the drawn sizes on the point type it makes. When every object already has the setting asked for, nothing changes and no undo step @@ -8621,6 +8820,20 @@ share of the content's width or height in proportion to the length of its contro its curve parameter, so content spreads evenly along a segment without handles. An envelope opened from an `.ai` file keeps the corners the file saves, and they stay on the same anchors when the envelope is moved, rotated or reflected. These are VectorCraft's own rules, and existing top-object envelopes redraw by them. +A warp bends the content's bounds as one Bézier patch. The style sets the patch's control points from the bend and +the size of the bounds (Arc's top and bottom edges, for example, are cubic curves close to arcs around one center), then +the horizontal distortion scales each column of control points about its middle, and the vertical distortion scales +each row. Content with no width or no height takes the middle of the patch along that side. New warp envelopes, and +those opened from `.ai` files, carry `version` 1 for this map, and `object.envelope.info` reports it. Envelopes in +`.vectorcraft` documents saved before the version existed read as `version` 0, the earlier map, and keep their look, +and editing an envelope keeps its version. `version` (0 or 1) on `object.envelope.makeWithWarp`, +`object.envelope.resetWithWarp` and `object.envelope.options` sets the map. The Warp effects (`warp.arc` … +`warp.twist`) take the same map through the same `version` param: `effect.apply` gives a new Warp `version` 1 unless +`params.version` is 0, the built-in graphic styles' Warps have 1, and Effect › Warp edits keep each Warp's own version. +These commands refuse any other value with `` `version` must be 0 or 1 ``; `1.0` counts as 1. An `.ai` file holds a +warp's settings and not its version, so a `version` 0 warp saved to `.ai` and opened again draws with `version` 1. +These patches are VectorCraft's own rules; the styles and the ranges of Bend and the distortions are those of the Warp +options (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). `object.envelope.info` reads the selected envelope's settings (`type`, the warp's or mesh's values, `fidelity` and the options, `editing`), or with no envelope selected the options new envelopes get. With an envelope selected, `object.envelope.resetWithWarp` and `object.envelope.resetWithMesh` (`rows`, `cols`, `maintainShape`: true keeps @@ -8930,6 +9143,30 @@ value the open path is too short for sets the distance, the drag also sets `spac objects stay that far apart. Shift and Alt do not change the drag. With measurement labels on, the first object's distance along the path shows next to the pointer in the document's units. Escape cancels the drag. +The same tools show a Select/Move widget, a small ring, on each object's attachment point; the ring under the pointer +is highlighted. A click on one chooses its object: it sends `object.objectsOnPath.selectMember {member}`, which +selects the group alone with the object chosen, and the chosen object's ring is filled blue. Any other selection +change clears the choice. A click on the object's art away from the ring selects the group as before. Dragging a ring +along the path moves its +object to the place of the object whose attachment point is nearest the pointer along the path, and previews +`object.objectsOnPath.moveMember {group, member, target}`; the release keeps it as one undo step and chooses the +object. A drag that ends on the object's own place, and Escape, keep nothing. On an open path a pointer beyond an end +takes the place at that end; on a closed path the drag goes on round across the first anchor. Where two places are as +near, the drag stays at the place it is at. A hidden or locked object has no widget, and a drag does not take or +shift its place. Shift and Alt do not change the drag. The widgets for Rotate All, Space and +Move All take a press before a Select/Move widget, and of Select/Move widgets on the same point a press takes the +chosen one, else the first along the path. + +`object.objectsOnPath.moveMember {member, target, group?}` moves `member` to `target`'s place along the path: the +objects between them each move one place toward where `member` was, except hidden and locked objects, which keep +their places and are passed over. Both must be objects of the same Objects on Path +group (not its path), and neither they nor the group may be hidden or locked; `group`, when given, must be that +group. Only the +order along the path changes: Space, the start, Pivot, Rotate and the children's order in the Layers panel stay. A +`target` that is `member` changes nothing and records no undo step. It returns `{group, member, index}`, the object's +new place along the path counted from 0. `object.objectsOnPath.selectMember {member}` takes the same kind of `member` +and returns `{group, member}`. + ```json {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"objectsOnPath","values":{"pivot":7,"rotate":30}}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.attach","params":{"path":30,"ids":[12,15,18]}}} @@ -9459,8 +9696,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 @@ -9511,6 +9749,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 @@ -9905,3 +10181,24 @@ Path Options' Preview does (#5360). {"name":"run_command","arguments":{"command":"addon.type.releasePath","params":{}}} {"name":"run_command","arguments":{"command":"addon.type.releasePath","params":{"mode":"path","keepOriginal":true}}} ``` + +## Save location errors + +A save refused by the filesystem with PermissionDenied or ReadOnlyFilesystem returns +`READ_ONLY_LOCATION: <reason>`. The reason retains the path and system message. This is a +stable prefix within the existing error string, not a new response field or JSON-RPC code. +An automation-root denial, an encoding failure, and an error retaining recovery copies do +not use this code. Choose a writable destination and explicitly save there; automation never +opens the fallback Save As panel. Failed saves keep the original document path and modified +state; multi-file saves may already have written other output files. + +`app.save` with `wait: true` returns the write failure in its reply (`save_file` already asks +for this on the desktop). Without waiting, an accepted background save still replies before +completion; any later failure opens no panel. MCP retains its usual tool-name prefix around +the error text. The interactive fallback applies only to ordinary File > Save, not Close/Quit. + +## Revision history + +| Date | Change | +|---|---| +| 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/plugins.md b/docs/plugins.md index d3af5fa06c..f616479167 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,9 +1,9 @@ # Plug-ins: the WebAssembly plug-in API (ABI v1) VectorCraft does not host native plug-ins built for other illustration apps: that needs unsafe FFI into -arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly modules** -run in a sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's -plug-in API (same sandbox, same manifest and parameter schema), extended for vector documents. +arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly modules** run in a +sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's plug-in API (same +sandbox, same manifest and parameter schema), extended for vector documents. There are four kinds of plug-in: @@ -20,8 +20,8 @@ There are four kinds of plug-in: paths and compound paths. File › Open lists their files, File › Open With lists the importers by name, and the host hands them the file's bytes. -This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the -`plugin.*` commands in `crates/engine/src/cmd/plugin.rs`). +This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the `plugin.*` commands +in `crates/engine/src/cmd/plugin.rs`). ## Using plug-ins @@ -44,14 +44,15 @@ An exporter adds a write-only format, `plugin.<id>`, which `document.formats` li its extensions that no built-in format has; `selectedOnly` hands it the selection, as for the built-in formats. An importer adds a read-only format, `plugin.<id>`, which `document.formats` lists with its `plugin` id. `openExtensions` -holds the extensions File › Open opens with the importer (the lists below describe which are left out). `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format -reads the file, by its content or by its extension, and the importer lists the file's extension; when several do, the -one whose id sorts first opens it. `document.open {"importer": "plugin.<id>", "params"?: {…}}` names the importer, -which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg` -only this way). A damaged file of a built-in format reports that format's error, and the file is not handed to an -importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high, -and within ±4,000,000 pt); the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats, and a file only an importer reads opens as a new document when it is dropped on the canvas. These rules are -VectorCraft's own. +holds the extensions File › Open opens with the importer (the lists below describe which are left out). `document.open +{"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format reads the file, by its content or +by its extension, and the importer lists the file's extension; when several do, the one whose id sorts first opens it. +`document.open {"importer": "plugin.<id>", "params"?: {…}}` names the importer, which must list the file's extension, +also one a built-in format reads (an importer that lists `svg` opens an `.svg` only this way). A damaged file of a +built-in format reports that format's error, and the file is not handed to an importer. The importer's objects become a +new document whose artboard is their bounds (at least 1 pt wide and high, and within ±4,000,000 pt); the document keeps +no path, so Save asks for a name. File › Place reads only the built-in formats, and a file only an importer reads opens +as a new document when it is dropped on the canvas. These rules are VectorCraft's own. In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with @@ -60,25 +61,23 @@ of numbers and colors, labelled, explained and sorted into sections as its manif parameters it runs at once. Edit › Keyboard Shortcuts lists each installed filter under Object › Plug-ins, where it can be given a shortcut (`plugin:<id>`); the shortcut stays saved while the plug-in is removed and applies again when it is reinstalled. Parameters too many for the window's height scroll inside the dialog, which keeps its -heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use -Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the -same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › -Open's "All readable files" and under a filter of their own, named after the importer (other open dialogs, such as New -from Template and Load Styles, list only the files the built-in formats read). These lists leave out an importer's -extensions that a built-in format reads, and those File › Open installs, imports or loads as something else (`.wasm`, -`.kys`, `.jsx`, presets, libraries and swatch or graphic style libraries such as `.ase`). File › Open does not give -those files to the importer, and Open With does. **File › Open With** lists the -installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its -extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. On the -desktop the importer runs off the UI thread, and a run that takes a while shows a progress window with Cancel, which -stops it. An importer -with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open -button opens the file; File › Open runs an importer with its parameters' defaults. Open Recent opens a file the way -File › Open does: it does not remember the importer or the parameters given to Open With. A built-in format reads the -file if one can, and otherwise the first importer by id that lists its extension reads it with its parameters' defaults. -**File › Open** of a `.wasm` file installs -a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the -background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed +heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use Artboards is greyed out +(the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is +picked, and its Export button writes the file. Importers' files are listed under File › Open's "All readable files" and +under a filter of their own, named after the importer (other open dialogs, such as New from Template and Load Styles, +list only the files the built-in formats read). These lists leave out an importer's extensions that a built-in format +reads, and those File › Open installs, imports or loads as something else (`.wasm`, `.kys`, `.jsx`, presets, libraries +and swatch or graphic style libraries such as `.ase`). File › Open does not give those files to the importer, and Open +With does. **File › Open With** lists the installed importers by name (`file.openWith {"importer", "path"?, +"params"?}`), and each opens a file picked among its extensions with that importer (on the web, File › Open's picker +opens instead) and adds it to Open Recent. On the desktop the importer runs off the UI thread, and a run that takes a +while shows a progress window with Cancel, which stops it. An importer with parameters, given no `params`, asks for them +in a dialog of the same kind once the file is picked, and its Open button opens the file; File › Open runs an importer +with its parameters' defaults. Open Recent opens a file the way File › Open does: it does not remember the importer or +the parameters given to Open With. A built-in format reads the file if one can, and otherwise the first importer by id +that lists its extension reads it with its parameters' defaults. **File › Open** of a `.wasm` file installs a plug-in +too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the background +([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). ### Commands @@ -117,9 +116,9 @@ the object is drawn as if the effect weren't there, and the record is kept for w ## The sandbox -The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssembly interpreter -(MIT OR Apache-2.0). It builds for `wasm32-unknown-unknown` too, so plug-ins also run in the web build. Limits -(`vectorcraft_plugins::Limits`, defaults): +The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssembly interpreter (MIT OR Apache-2.0). It +builds for `wasm32-unknown-unknown` too, so plug-ins also run in the web build. Limits (`vectorcraft_plugins::Limits`, +defaults): | Limit | Default | On violation | |---|---|---| @@ -155,15 +154,15 @@ measured with the page's `performance.now()`. Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a simulation, which a minute and 50 M instructions don't cover. Such a filter declares `"budget": "heavy"` in its manifest (only a filter may: a live effect runs whenever the document is drawn, an exporter has an export's budget and -an importer an import's). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s -(`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns how -much there is only once the run has ended. +an importer an import's). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, +and 300 s (`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns +how much there is only once the run has ended. - **Installing asks first.** Install Plug-in… and File › Open show what the filter asks for, and install it once the user clicks OK (see [Permissions](#permissions) for who can). `plugin.install` refuses it without `allowHeavy: true`, and refuses `allowHeavy` from a request except in a session started with `vectorcraft-cli --allow-plugin-grants` - (`plugin.inspect` shows the budget beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed again - at startup. The Additional Plug-ins Folder skips heavy filters with a note in its report, since loading a folder + (`plugin.inspect` shows the budget beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed + again at startup. The Additional Plug-ins Folder skips heavy filters with a note in its report, since loading a folder asks nobody. - **It runs in the background.** Its dialog has no Preview. OK (or the menu item, without parameters) runs it on a thread of its own while editing goes on, shown in the status bar and, after a moment, in a progress window with @@ -203,17 +202,15 @@ capability model proposed for PhotoCraft plug-ins `state`, which only its commands use. A filter may list any of its three, and `commands` with a `commandAllowlist`. Any kind may add `progress`, `settings` and `cache`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, `progress`, `settings`, `cache` or `commands` with an `api` - below 2, or - one of the names kept for a later plug-in API - (`ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. + below 2, or one of the names kept for a later plug-in API (`ui.panel`, `ui.tool`) is refused when the module is + installed, and the error names it. - **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of the file and each permission in plain words, and install it once the user clicks OK, if the file still has the - SHA-256 shown; Cancel installs nothing. The - names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. - Only a person can press its OK: `ui.dialog.confirm`, MCP's `answer_dialog` and injected input (`ui.key`, `ui.click`) - are refused and the dialog stays open. Enter confirms it only when OK has the keyboard focus. `ui.dialog.set` can't - change what it installs. OK stays disabled until the question has been on screen in the focused window for half a - second (VectorCraft's own value, from the delay the security guidance at + SHA-256 shown; Cancel installs nothing. The names shown have control and bidirectional characters escaped (`\u{202E}`) + and are cut to 64 characters. Only a person can press its OK: `ui.dialog.confirm`, MCP's `answer_dialog` and injected + input (`ui.key`, `ui.click`) are refused and the dialog stays open. Enter confirms it only when OK has the keyboard + focus. `ui.dialog.set` can't change what it installs. OK stays disabled until the question has been on screen in the + focused window for half a second (VectorCraft's own value, from the delay the security guidance at https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/security/security-considerations-for-browser-ui.md gives for dialog boxes), so a click, Enter or Space, or a press begun before then, installs nothing. The half second starts over for every new question, even one that reads the same, and when the window gets the focus back. Cancel @@ -280,23 +277,22 @@ capability model proposed for PhotoCraft plug-ins does not count for any session in the same process that uses the same `Plug-ins` folder, by any path to it, until `grants.json` can be written for that plug-in, as when the plug-in is granted again. Another process on the same folder, for example `vectorcraft-cli` beside the app, still reads `grants.json` as it is written. A grant for the - same id kept in another `Plug-ins` folder counts. In Safe Mode, and for - a stored module that the user disabled, + same id kept in another `Plug-ins` folder counts. In Safe Mode, and for a stored module that the user disabled, VectorCraft does not read the module, and `grants.json` keeps the grant for its id. The Additional Plug-ins Folder and `plugin.reload` do not change the `Plug-ins` folder: when they leave a module that lists no permissions under an id, the process forgets its own grant for that id, and a grant kept in `grants.json` stays with the stored module it covers. An entry of `grants.json` that this version can't use (a permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again, replaced by a module that lists no permissions, or removed. When such an entry can't be removed from the file, `plugin.install` and `plugin.remove` treat it as a grant - that can't be removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than - 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept - in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, - a stored plug-in is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins - Folder and `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a + that can't be removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written + larger than 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept in it count for + `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, a stored plug-in + is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins Folder and + `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a `permissions` list, or with an empty one, needs no grant. -- The host enforces the grant on a filter run and a command run: without `doc.read` the filter is given no objects; without `state` its - `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the - document is refused. A refused output leaves the document as it was. +- The host enforces the grant on a filter run and a command run: without `doc.read` the filter is given no objects; + without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that + changes the document is refused. A refused output leaves the document as it was. - `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host imports](#host-imports-api-level-2), a filter's `inputs` ([reading type, images and symbols](#reading-type-images-and-symbols)) and a live effect's [`paintOutput`](#painted-live-effects). A higher one @@ -307,12 +303,11 @@ capability model proposed for PhotoCraft plug-ins - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls except what a plug-in writes to its -[settings and cache](#settings-and-cache). Traps, exhausted budgets, malformed -modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the -document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that -fails draws the object's geometry unchanged; `plugin.info` reports why (`lastError`). A run that succeeds clears the -error of a failed run. A check that finds every swatch and pattern a painted result names clears only the error of a -refused swatch or pattern. +[settings and cache](#settings-and-cache). Traps, exhausted budgets, malformed modules, bad manifests, out-of-range +pointers, negative return codes and invalid output all become errors, and the document is left untouched: a filter's +output is checked completely before one edit applies it. A live effect that fails draws the object's geometry unchanged; +`plugin.info` reports why (`lastError`). A run that succeeds clears the error of a failed run. A check that finds every +swatch and pattern a painted result names clears only the error of a refused swatch or pattern. wasmi is built with its *portable* (loop) dispatcher: the default tail-call dispatcher relies on LLVM sibling-call optimisation, and when that doesn't happen a long-running module overflows the host stack, which aborts @@ -365,12 +360,12 @@ still returns `1`. `vc_progress`, `vc_cancelled` and `vc_log` need `progress`; t filter that lists `progress` runs in the background as a heavy one does, under its light budget, when it runs at once from Object › Plug-ins or its shortcut, or when OK runs it with Preview off; with Preview on, its dialog keeps the previewed run. A filter that also lists a `commandAllowlist` runs where it is called, since its command calls need - the document. A plug-in that checks `vc_cancelled` can - stop early, and the host discards what it returns after the Cancel or after its time budget ran out; one that - ignores it is still stopped by the instruction and time budgets. With a known total, a report is passed on only when - it reaches another tenth of a percent; with an unknown total, each change of `done` is passed on. After 2,000 reports - in a run, one change in 1,000 is passed on, and so is the first report after the total becomes known or unknown. A - live effect's progress is not shown, since it is drawn as part of something else. + the document. A plug-in that checks `vc_cancelled` can stop early, and the host discards what it returns after the + Cancel or after its time budget ran out; one that ignores it is still stopped by the instruction and time budgets. + With a known total, a report is passed on only when it reaches another tenth of a percent; with an unknown total, each + change of `done` is passed on. After 2,000 reports in a run, one change in 1,000 is passed on, and so is the first + report after the total becomes known or unknown. A live effect's progress is not shown, since it is drawn as part of + something else. - Log lines go to the app's log (target `vectorcraft_plugins`) as `<plug-in id>: <text>`, with control and bidirectional characters escaped. The whole text must be UTF-8. A line shows its first 1,024 bytes, as at most 1,024 characters with the escapes, and ends with `…` when it is cut. A run writes at most 200 lines and 32 KiB of shown @@ -405,8 +400,7 @@ or modified time differs from the last read or write. A get answered from memory set to the value the key already has writes nothing, and neither does a delete of a key that has no value. A delete of the last setting removes the file and counts no written bytes. Every other successful set or delete writes the file before the call returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and -the run waits for it. -A live effect runs while the art draws, so keep the entries it uses small. +the run waits for it. A live effect runs while the art draws, so keep the entries it uses small. - A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, in any case). Keys differ in case: `tile` and `Tile` are two. @@ -416,28 +410,27 @@ A live effect runs while the art draws, so keep the entries it uses small. also fails with `-4` when the `cache` folder holds more than 8,192 items; clearing the cache removes every file in it. - The desktop app and `vectorcraft-cli` keep them in a folder named from the id under `storage/` in the `Plug-ins` - folder (ids that differ only in case get two folders): `settings.json` and a - `cache` folder with one file per entry, named by the host. A settings file that is too large or doesn't parse is - moved to `settings.unreadable.json`, replacing an earlier one there, and the settings start empty. A link in that - folder is refused (`-5`). Writes go to a temporary file that then replaces the old one; a temporary file a failed - write leaves in the `cache` folder does not count toward the caps, and clearing the cache removes it. + folder (ids that differ only in case get two folders): `settings.json` and a `cache` folder with one file per entry, + named by the host. A settings file that is too large or doesn't parse is moved to `settings.unreadable.json`, + replacing an earlier one there, and the settings start empty. A link in that folder is refused (`-5`). Writes go to a + temporary file that then replaces the old one; a temporary file a failed write leaves in the `cache` folder does not + count toward the caps, and clearing the cache removes it. - A plug-in reaches only the storage of its own id. Replacing it with another module of the same id - (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a run of the plug-in still going gets `-5` from every storage call after that. - `plugin.remove` first renames the folder, adding `.removed` to its name (or `.removing` while a `.removed` folder - holds settings an earlier removal kept). When that fails, `plugin.remove` fails and the plug-in stays installed with - its storage as it was. Before VectorCraft deletes a renamed folder, it writes an empty file beside it with `.delete` - added to the folder's name, and removes that file once the folder is gone. When that file can't be written, the - folder is not deleted and is treated like any renamed folder without a `.delete` file (see below). Files that can't be deleted stay in the - renamed folder with the `.delete` file beside it, where no plug-in reaches them. A later `plugin.remove` tries again - to delete them when a plug-in with that id is installed or a storage folder for the id exists, and that deletion can - fail too. For an id that isn't installed and has only such a folder, `plugin.remove` does not delete it and fails as - not found. When `plugin.remove` can't remove the grant and can't rename the folder back either, its error names the - renamed folder. The next load, install or `plugin.remove` of the plug-in renames a renamed folder that has no - `.delete` file back while the plug-in has no folder of its own; when it has one, that folder is used and the renamed - folder stays until `plugin.remove` deletes both. - `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept for that id, when there is - any. - `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and + (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a + run of the plug-in still going gets `-5` from every storage call after that. `plugin.remove` first renames the folder, + adding `.removed` to its name (or `.removing` while a `.removed` folder holds settings an earlier removal kept). When + that fails, `plugin.remove` fails and the plug-in stays installed with its storage as it was. Before VectorCraft + deletes a renamed folder, it writes an empty file beside it with `.delete` added to the folder's name, and removes + that file once the folder is gone. When that file can't be written, the folder is not deleted and is treated like any + renamed folder without a `.delete` file (see below). Files that can't be deleted stay in the renamed folder with the + `.delete` file beside it, where no plug-in reaches them. A later `plugin.remove` tries again to delete them when a + plug-in with that id is installed or a storage folder for the id exists, and that deletion can fail too. For an id + that isn't installed and has only such a folder, `plugin.remove` does not delete it and fails as not found. When + `plugin.remove` can't remove the grant and can't rename the folder back either, its error names the renamed folder. + The next load, install or `plugin.remove` of the plug-in renames a renamed folder that has no `.delete` file back + while the plug-in has no folder of its own; when it has one, that folder is used and the renamed folder stays until + `plugin.remove` deletes both. `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept + for that id, when there is any. `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and `plugin.info` give the cache's size as `cacheBytes`. - Without a `Plug-ins` folder (the web build, and embedders that attach none) the storage calls return `-5`. @@ -458,8 +451,9 @@ has the id, `-6` when a plug-in may not call the command, `-7` when the command `-8` when the command ran and failed, `-9` when a limit below is passed, and `-11` when nothing can be called (from the module's start function, in a run that isn't a filter's run on a document, or after a call in the run failed). -- Every code also fails the run, whatever the plug-in does next, and the error names the call and why it failed. The run's calls and its output are one undo step, named after the plug-in or its command. A run that fails, - traps, runs out of budget or is canceled leaves every document, its selection and its history as they were. +- Every code also fails the run, whatever the plug-in does next, and the error names the call and why it failed. The + run's calls and its output are one undo step, named after the plug-in or its command. A run that fails, traps, runs + out of budget or is canceled leaves every document, its selection and its history as they were. - Each call runs on the document the run is for, with its params checked as any request's are, and sees what the calls before it changed: a `select.all` followed by `object.move` moves everything. The filter's own input is the one it was given when the run started; an edit still in progress (typing, or a shape being drawn) is kept as its own undo @@ -540,18 +534,17 @@ the module's start function, in a run that isn't a filter's run on a document, o no empty part between dots. A malformed list is refused when the module is installed, and the error names the id. - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or `preview`. The host validates the user's values before calling: missing values take the default, numbers are - clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults - are also clamped to their range, including the implicit zero default of an `int` parameter. Besides `number`, - `int`, `bool` and `choice` there are `string` (`{"type": "string", "default": "", "maxLength": 256}`: a text field; - longer text is cut to `maxLength`, at most 4,096 characters) and `numberList` (`{"type": "numberList", "min": 0, - "max": 360, "default": [45, 135], "minItems": 1, "maxItems": 16}`: numbers typed separated by commas or spaces, - each clamped to `min..max`; a list of too few or too many is an error, and `maxItems` is at most 256) and `color` - (`{"type": "color", "default": "#3a7bd5"}`: a color button that opens a color picker; the default, black when left - out, and a value given to `plugin.run` may be a color object as paints hold one or a hex string, and the plug-in - always gets a color object, its components clamped, such as `{"model": "rgb", "r": 0.23, "g": 0.48, "b": 0.84}`; a - hex string and the picker's choice are display colors, given as the working RGB color that shows as them, so in a - working RGB space other than sRGB the numbers differ from the hex). Hosts from before `color` refuse a manifest that - uses it. A plug-in declares up to 256 + clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults are also clamped + to their range, including the implicit zero default of an `int` parameter. Besides `number`, `int`, `bool` and + `choice` there are `string` (`{"type": "string", "default": "", "maxLength": 256}`: a text field; longer text is cut + to `maxLength`, at most 4,096 characters) and `numberList` (`{"type": "numberList", "min": 0, "max": 360, "default": + [45, 135], "minItems": 1, "maxItems": 16}`: numbers typed separated by commas or spaces, each clamped to `min..max`; a + list of too few or too many is an error, and `maxItems` is at most 256) and `color` (`{"type": "color", "default": + "#3a7bd5"}`: a color button that opens a color picker; the default, black when left out, and a value given to + `plugin.run` may be a color object as paints hold one or a hex string, and the plug-in always gets a color object, its + components clamped, such as `{"model": "rgb", "r": 0.23, "g": 0.48, "b": 0.84}`; a hex string and the picker's choice + are display colors, given as the working RGB color that shows as them, so in a working RGB space other than sRGB the + numbers differ from the hex). Hosts from before `color` refuse a manifest that uses it. A plug-in declares up to 256 parameters, at most 64 of them outside groups. Each parameter may also say how dialogs show it. Hosts from before these keys ignore them, so a plug-in that @@ -576,17 +569,18 @@ parameters uses `label`, `unit`, `help` and `visibleWhen`, and lists the paramet order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide (compound guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never touches unless its manifest lists them in `inputs` ([reading type, images and -symbols](#reading-type-images-and-symbols)). With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one -object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and `strokes`, as a filter gets them (hosts -from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a -fill's colour to decide its shape, but it changes only the geometry, unless it declares `paintOutput` ([painted live -effects](#painted-live-effects)). +symbols](#reading-type-images-and-symbols)). With nothing selected the list is empty (a generator then adds its own +objects). A live effect gets one object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and +`strokes`, as a filter gets them (hosts from before these give geometry only). The paints are read only: an effect may +draw as wide as a stroke or use a fill's colour to decide its shape, but it changes only the geometry, unless it +declares `paintOutput` ([painted live effects](#painted-live-effects)). An **exporter** gets something else: the native `.vectorcraft` document JSON without indentation, optional preview/PDF -renditions or save-preference metadata (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, -appearance and effect record, artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks -`version`, since a later host may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME -type and `blob` but without their data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. +renditions or save-preference metadata (the format is documented in +[`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, appearance and effect record, +artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks `version`, since a later host +may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME type and `blob` but without their +data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. An **importer** gets the bytes of the file being opened, as they are (not JSON), at most 128 MiB of them. @@ -615,12 +609,11 @@ An **importer** gets the bytes of the file being opened, as they are (not JSON), (absent: no handle), `kind` `"Smooth"` when the handles stay collinear. Coordinates are points, y down. - `fills`, `strokes`: the object's fills and strokes in paint order (bottom first); `null` is no paint. A stroke has its `paint`, `width` in points, `join` (`"miter"`, `"round"` or `"bevel"`), `cap` (`"butt"`, `"round"` or `"square"`) - and `miterLimit`. Paints use the - native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, `{"type": "gradient", "gradient": - {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, midpoint}], "dither"?, "method"?: "Perceptual"}, "geom"?, "angle", - "freeform"?}`, `{"type": "pattern", "pattern", "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", - c, m, y, k}`, `{"model": "gray", k}` (ink: 0 white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except - Lab's. + and `miterLimit`. Paints use the native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, + `{"type": "gradient", "gradient": {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, + midpoint}], "dither"?, "method"?: "Perceptual"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern", + "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", c, m, y, k}`, `{"model": "gray", k}` (ink: 0 + white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except Lab's. - `effects` (filters): the object's own effects in order, as its effect records: `id` (as `effect.list` lists it, or `plugin.<id>` for a live-effect plug-in), `params` and `visible`. Its fills' and strokes' own effects aren't listed. - `bounds`: `[x0, y0, x1, y1]`, the geometric bounds (read only). @@ -631,9 +624,9 @@ A filter whose manifest lists `inputs` also gets the selected objects of those k and under the same rules (hidden and locked ones, objects that clip a clip group, alone or inside a group that clips it, and anything inside a symbol, a placed document or a live object are left out). They are never turned into outlines, rectangles or the symbol's art. Each has `id`, `type`, `name` (when it has one), `bounds` (`null` until the -object has been laid out or drawn) and -`transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, the symbol's art) into the document. -In this example the character and paragraph styles are cut short; a filter gets every attribute: +object has been laid out or drawn) and `transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, +the symbol's art) into the document. In this example the character and paragraph styles are cut short; a filter gets +every attribute: ```json {"objects": [ @@ -662,13 +655,13 @@ In this example the character and paragraph styles are cut short; a filter gets `pixelsOmitted: false`. A CMYK image's pixels are its inks through the working CMYK profile, as sRGB. The pixels of all the images in one input take at most 16 MiB in base64, and an image the host starts to decode takes its size from those 16 MiB even when decoding fails. A larger image, one whose pixels don't fit what the images before - it in paint order left, and a linked image (`linked: true`) have - `pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. An image of any - size also has `pixelsOmitted: true` when its data is missing from the document, when its width or height is 0, when - its data cannot be decoded (including a non-CMYK image whose decoder enforces the host's 64 MiB memory limit and - needs more, and a TIFF the host rejects as too large), or when the dimensions in its data differ from `pixelSize`. - A plug-in checks `pixelsOmitted` before it reads `pixelsBase64`, which is present only when `pixelsOmitted` is `false`. The host never - reads a linked image's file for a filter and never gives its path. + it in paint order left, and a linked image (`linked: true`) have `pixelsOmitted: true` and no pixels; the host checks + an image's size before it decodes the image. An image of any size also has `pixelsOmitted: true` when its data is + missing from the document, when its width or height is 0, when its data cannot be decoded (including a non-CMYK image + whose decoder enforces the host's 64 MiB memory limit and needs more, and a TIFF the host rejects as too large), or + when the dimensions in its data differ from `pixelSize`. A plug-in checks `pixelsOmitted` before it reads + `pixelsBase64`, which is present only when `pixelsOmitted` is `false`. The host never reads a linked image's file for + a filter and never gives its path. - A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the document has no symbol of that name. `overrides` is `{}`. @@ -699,16 +692,16 @@ toward the 64 MiB output cap. ``` `mode` is `"filter"`, `"effect"`, `"export"`, `"import"` or `"command"` (a [command](#commands) run, whose -`command` is the command's id); `bounds` the selection's geometric bounds (a live effect: the reference -box of the effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard -under the selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document -(`null` until it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the -object's effects), or `{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke, -counted from the bottom as in `fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt", -"name": "Poster"}` instead: the extension of the file being written (one the exporter lists) and its name without the -extension (the document's name when the export is not given a path), which some formats keep in their header. An -import's `_context` is `{"mode": "import", "extension": "plt", "name": "Poster"}`: the extension of the file being -opened and its name without the extension. +`command` is the command's id); `bounds` the selection's geometric bounds (a live effect: the reference box of the +effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard under the +selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document (`null` until +it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the object's effects), or +`{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke, counted from the bottom as in +`fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt", "name": "Poster"}` instead: +the extension of the file being written (one the exporter lists) and its name without the extension (the document's name +when the export is not given a path), which some formats keep in their header. An import's `_context` is `{"mode": +"import", "extension": "plt", "name": "Poster"}`: the extension of the file being opened and its name without the +extension. ### Output @@ -785,17 +778,17 @@ is a piece drawn by itself, in the order returned, with paints of its own: path or compound path is hit-tested on its source path and appearance. On an object with no geometry effect of its own, Pathfinder effects and compound shapes use the object's path for an effect on a fill or stroke, as they do for every other effect on a fill or stroke; on an object that has one, they also use the pieces of a painted effect on a - fill or stroke. A 3D effect later in the stack uses the - object's input path when the pieces name a swatch or pattern. Each piece counts against the budget of the - object's geometry effects as well as its anchors, so all the painted effects in a stack make at most 100,000 pieces - together; a run whose pieces would pass the budget draws its input as it was. + fill or stroke. A 3D effect later in the stack uses the object's input path when the pieces name a swatch or pattern. + Each piece counts against the budget of the object's geometry effects as well as its anchors, so all the painted + effects in a stack make at most 100,000 pieces together; a run whose pieces would pass the budget draws its input as + it was. - The result is checked whole before any of it is drawn: the limits of a filter's output (100,000 objects, 4,000,000 anchors, 256 fills and 256 strokes per piece, 256 stops per gradient), finite numbers in range with nothing clamped (color components, tints, offsets, opacities, midpoints 0.13 to 0.87, stops in order of `offset`, stroke - widths 0 to 1,000 pt, miter limits 1 to 500), and every swatch a solid paint, gradient or stop links to and every pattern named in - the document. One fault refuses the whole result: that step of the stack draws its input as it was, the effect - record stays, and `plugin.info`'s `lastError` names the plug-in and the field, for example - `Test Tone: plug-in output: objects[1].strokes[0].width: …`. + widths 0 to 1,000 pt, miter limits 1 to 500), and every swatch a solid paint, gradient or stop links to and every + pattern named in the document. One fault refuses the whole result: that step of the stack draws its input as it was, + the effect record stays, and `plugin.info`'s `lastError` names the plug-in and the field, for example `Test Tone: + plug-in output: objects[1].strokes[0].width: …`. - The pieces are never written into the document while it is drawn, measured, previewed, saved or exported. PDF and SVG export draw them as the canvas does, from a copy of the document. Object › Expand Appearance writes each piece as an ordinary path or compound path with its fills and strokes, as one undo step. Object › Expand of gradient and pattern @@ -823,12 +816,13 @@ crates/plugins/example/build.sh # cargo build --release --target wasm32-unk The script also refreshes `crates/plugins/tests/fixtures/desaturate.wasm`, the test fixture built from that source. The crate is not a workspace member. Any language that targets core WebAssembly works (C, Zig, AssemblyScript, -WAT…), as long as the module imports nothing but the host imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never -touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, which the tests -build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters (with a -base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, and -`wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows in a -live effect's geometry. `wat::reporting` and `wat::cooperating` build level 2 plug-ins that import the host functions. +WAT…), as long as the module imports nothing but the host imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as +long as it never touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, +which the tests build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters +(with a base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, +and `wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows +in a live effect's geometry. `wat::reporting` and `wat::cooperating` build level 2 plug-ins that import the host +functions. ## Performance diff --git a/docs/roadmap.md b/docs/roadmap.md index 51b0eb31bb..4cc08b406d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -48,33 +48,33 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops from the left edge (#6147). Scores and estimates unchanged. | -| 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-10 | minor | M13 / G6: MCP catalogue (#6460): headless lists UI commands, eight prompts, 33 tools, effect vs command ids; scores and estimates unchanged. | | 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | -| 2026-11-10 | minor | M13 / G6: MCP catalogue (#6460): headless lists UI commands, eight prompts, 33 tools, effect vs command ids; scores and estimates unchanged. | +| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops from the left edge (#6147). Scores and estimates unchanged. | +| 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-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, 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-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-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). | | 2026-11-05 | minor | Pencil and Paintbrush: a new stroke with Keep selected off leaves nothing selected, the earlier selection included, while Keep selected on selects only the new path (#5752); scores and estimates unchanged. | | 2026-11-05 | minor | Layers panel: a layer whose Layer Options › Print is off shows its name italic, as the reference application shows non-printing layers, and keeps it while the row is dragged (#5739); scores and estimates unchanged. | +| 2026-11-05 | minor | Panels: a floating panel window hides under a modal dialog and drops out of the top level under an open menu, so neither the Colour Picker or Recolor Artwork nor the menu bar is covered, and it comes back at its palette level after (#5238); scores and estimates unchanged. | | 2026-11-04 | minor | M7: 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); estimates unchanged. | | 2026-11-04 | minor | M7: Find Font's Replace With Font lists family and style rows with a search, and the list and the document's font list scroll on their own (#4972); scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | M4: raster/PSD exports default to document resolution; CLI run/convert expose --ppi, matching the engine and MCP precedence. Scores and hours unchanged. | -| 2026-11-05 | minor | Panels: a floating panel window hides under a modal dialog and drops out of the top level under an open menu, so neither the Colour Picker or Recolor Artwork nor the menu bar is covered, and it comes back at its palette level after (#5238); scores and estimates unchanged. | | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep the units their document or preset was made in, and the built-in tabs still follow the chosen units (#2078). Scores and estimates unchanged. | | 2026-11-03 | minor | M1: Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot; estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | +| 2026-11-03 | minor | M7: Find Font replacement sources Document, Recent and System are shipped (#5217); estimates unchanged. | +| 2026-11-03 | minor | M7: composition spacing remains along the line when characters rotate; estimates unchanged. | +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-10-30 | minor | M4: G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | | 2026-10-30 | minor | M4: File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; scores and estimates unchanged. | -| 2026-11-03 | minor | M7: Find Font replacement sources Document, Recent and System are shipped (#5217); estimates unchanged. | | 2026-10-30 | minor | Raster effects: bounded length fields in effect dialogs clamp on commit and reject invalid inputs visibly (#4638); estimates unchanged. | -| 2026-11-03 | minor | M7: composition spacing remains along the line when characters rotate; estimates unchanged. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-2; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile subsequent upstream progress updates without changing the runtime or repeating completed evidence; retain every dated upstream and feature revision. | @@ -143,17 +143,17 @@ 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) | 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) | 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, seven generated libraries including Bristle Brushes, 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, 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 | | 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 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Icon-strip flyouts resize on their free edges and retain sizes (#5713); interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 18–28 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Icon-strip flyouts resize on their free edges and retain sizes (#5713); interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326; a release more than 1 pt from the press keeps the caret, a tap within 1 pt selects all, #6659); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 | | M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above | @@ -190,8 +190,12 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 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. | | 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 | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | | 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged | | 2026-11-10 | minor | M10/G5: Status Effects built-in graphic style library; MCP docs catch-up for UI Frames and Map Markers; estimates unchanged | @@ -202,17 +206,17 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-10 | minor | M7: Spelling › Auto Spell Check in the context menu of type (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | M7: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | Current focus: the Effect Gallery's filter thumbnails and in-dialog preview are in (G2). Scores and estimates of other areas unchanged. | +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | +| 2026-11-10 | minor | Vector Halftone Preview leaves the dots unobscured and keeps the selection box (#6369). Scores and estimates unchanged. | | 2026-11-09 | minor | M7: Auto Spell Check (#4936); the dialogs and the right-click Spelling menu remain; scores and estimates unchanged. | | 2026-11-09 | 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. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. | | 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-09 | minor | M7: the custom dictionary commands and Ignore All (#4936); the dialogs and Auto Spell Check remain; scores and estimates unchanged. | | 2026-11-09 | minor | M4/G9: EPS import: 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. | +| 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-08 | minor | M7: spell check engine, `text.spelling.check` and `text.spelling.suggest` on craft-proof (#4936); the dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. | | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | -| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | -| 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | -| 2026-11-10 | minor | Vector Halftone Preview leaves the dots unobscured and keeps the selection box (#6369). Scores and estimates unchanged. | | 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); estimates unchanged | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | | 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. | @@ -220,13 +224,17 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep their own units; scores and estimates unchanged. | | 2026-11-03 | minor | M7: the OpenType panel offers the selected font's available Stylistic Sets with custom or numbered names; estimates unchanged. | | 2026-11-03 | minor | M1: Enclosing Lasso adds freehand whole-object selection under Object › Plug-ins; estimates unchanged. | -| 2026-11-02 | minor | Font-family fields in Character, Properties and Control step selected text with the wheel (#5253), with one undo step per notch; scores and estimates unchanged. | | 2026-11-03 | minor | M7 records Find Font's shipped replacement-source chooser (#5217); estimates unchanged. | | 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides (#5280). Guides outside the artwork remain unsupported. Scores and hours remain unchanged. | | 2026-11-03 | minor | M7: Touch Type moves right-to-left characters along their line while keeping text editable; first-logical-character restriction retained. Estimates unchanged. | -| 2026-10-30 | patch | M5: opaque solid-fill knockout paths avoid redundant erase/add layers in hard-edge frames and supersampled raster export; exact-pixel regression coverage retains analytical-AA rounding and translucent knockout behavior; scores and estimates unchanged | | 2026-11-03 | minor | M7: retain composition trimming in rotated character placement and rotation metadata; estimates unchanged. | +| 2026-11-02 | minor | Font-family fields in Character, Properties and Control step selected text with the wheel (#5253), with one undo step per notch; scores and estimates unchanged. | +| 2026-10-30 | patch | M5: opaque solid-fill knockout paths avoid redundant erase/add layers in hard-edge frames and supersampled raster export; exact-pixel regression coverage retains analytical-AA rounding and translucent knockout behavior; scores and estimates unchanged | | 2026-10-29 | minor | M6: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; scores unchanged | +| 2026-10-28 | minor | Flare ray rotation follows vertical travel at one degree per document point, independent of pointer event count. Shift freezes the current orientation; Space also freezes rotation on the first update. This is the chosen application interaction rate. Scores and estimates unchanged. | +| 2026-10-28 | minor | Drop Shadow Darkness subtracts the normalized amount from RGB source channels or adds it to CMYK/Gray black before profile conversion and blur; near-100% becomes gray black, with shadow opacity separate. SVG keeps live filters for uniform sRGB sources and renders shadows for other RGB profiles, source models and gradients. Gradient-stop adjustment is an application rule; spot/custom colors and composite-source parity remain open. Scores and estimates unchanged. | +| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | +| 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | | 2026-10-26 | minor | M4/G9: EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; it does not write Illustrator's own editing data (#868), and G9's real-file import fidelity is unchanged; scores and estimates unchanged | | 2026-10-25 | minor | M10/G1: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); estimates unchanged | | 2026-10-24 | minor | Docs catch-up: Current focus's three G11 lines merged into one (#864 and #885 remain; #937, #983/#1037 and #834 fixed) and its two stale G2 lines (filters left, the Effect Gallery to build) removed; G2 stays the gallery's thumbnails and in-dialog preview, 1–2 h. Estimates unchanged. | @@ -235,12 +243,10 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-23 | minor | M2/G1: a double-click on the Line Segment, Arc, Rectangular Grid or Polar Grid tool opens its Tool Options (#2691, in part); estimates unchanged | | 2026-10-23 | minor | G12 and M14: General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394); estimates unchanged | | 2026-10-23 | minor | M7/G4: Glyph Scaling leaves hung punctuation at its width and Standard burasagari counts a hung line start as room; paragraph-level hanging tests. Scores and hours unchanged. | -| 2026-10-20 | minor | M7: a paragraph's Auto Leading also drives the Character panel's Leading, the leading nudge and Fill with Placeholder Text; estimates unchanged | -| 2026-10-22 | minor | #834 (bezier drag preview stalled after the pointer re-entered the window, and missed the move in the release's frame) fixed; G11 keeps its 8–15 h | -| 2026-10-22 | minor | The Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects (#885). | -| 2026-10-18 | minor | #937's reproduced case fixed (Shape Builder lines cut art past 4,096 segments); G11 keeps 8–15 h until confirmed on the reporter's file | | 2026-10-23 | minor | M6: Simplify of part of a path: with anchors or segments of a path direct-selected, Object › Path › Simplify (slider, Auto-Simplify and dialog) refits only each run of selected segments between its end anchors; estimates unchanged | | 2026-10-23 | minor | M6: Simplify: Object › Path › Simplify opens its slider at the Auto-Simplify level for the selection, previewing that result, instead of at 1 pt; partial-path simplify remains missing; estimates unchanged | +| 2026-10-22 | minor | #834 (bezier drag preview stalled after the pointer re-entered the window, and missed the move in the release's frame) fixed; G11 keeps its 8–15 h | +| 2026-10-22 | minor | The Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects (#885). | | 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | | 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h). | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | @@ -249,13 +255,14 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-21 | minor | M14: workspaces keep the layout changes made in them across switches and restarts until Reset <name> (UI.7); estimates unchanged | | 2026-10-21 | minor | M14: panels dock by dragging into any dock group (tabs or a top/bottom split) or the icon column; dock tabs close from their context menu (UI.6); estimates unchanged | | 2026-10-21 | minor | M14: a double-click on a dock tab collapses its group to its tabs and expands it again (UI.8); estimates unchanged | -| 2026-10-20 | minor | M14: workspaces keep the layout changes made in them across switches and restarts until Reset <name> (UI.7); estimates unchanged | | 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | | 2026-10-21 | minor | 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. Scores and estimates unchanged. | | 2026-10-21 | minor | Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. | | 2026-10-21 | minor | Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. | | 2026-10-21 | minor | SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | +| 2026-10-20 | minor | M7: a paragraph's Auto Leading also drives the Character panel's Leading, the leading nudge and Fill with Placeholder Text; estimates unchanged | +| 2026-10-20 | minor | M14: workspaces keep the layout changes made in them across switches and restarts until Reset <name> (UI.7); estimates unchanged | | 2026-10-20 | minor | Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; existing opening gestures retained; scores and hours unchanged. | | 2026-10-20 | minor | M12: consolidated three duplicate milestone rows into one, retaining distinct Image Trace and Graphs details and the quick-preset wording correction; status, scores and 6–10 h estimate unchanged. | | 2026-10-20 | minor | Move the live-trace bounds/highlights note into the Image Trace milestone description and restore the Graphs label-formatting code span; implementation, scores and hours unchanged. | @@ -263,10 +270,11 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-20 | minor | M3.57: CSS swatch libraries preserve fractional stop positions in linear and radial gradients; synthetic color-crate regression. Scores and hours unchanged. | | 2026-10-19 | minor | Cache status-readout sample widths per UI, language, document unit and pixels per point; regressions check reuse across live frames and refresh after display-setting changes. Scores and estimates unchanged. | | 2026-10-19 | minor | Status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates. Contributor original-file acceptance confirmed; scores and estimates unchanged. | -| 2026-10-18 | minor | M4/DXF #1299: circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes retain POLYLINE width and closed partial arcs with an implicit closing chord are not promoted to CIRCLE. Scores and hours unchanged. | | 2026-10-19 | minor | Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Regression coverage; scores and estimates unchanged. | | 2026-10-19 | minor | Cached canvas reprojection preserves scale during resize, rotation and DPI changes. Contributor original-file acceptance confirmed; scores and estimates unchanged. | | 2026-10-19 | minor | Color Picker layout (#2008): compact controls, centered header, stacked actions and aligned inputs; screen-reader channel values, current shared button names and styling, screen sampler and model-by-model Tab order retained. Narrow windows scroll vertically; scores and hours unchanged. | +| 2026-10-18 | minor | #937's reproduced case fixed (Shape Builder lines cut art past 4,096 segments); G11 keeps 8–15 h until confirmed on the reporter's file | +| 2026-10-18 | minor | M4/DXF #1299: circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes retain POLYLINE width and closed partial arcs with an implicit closing chord are not promoted to CIRCLE. Scores and hours unchanged. | | 2026-10-18 | minor | M4: PDF Place and clipboard paste preserve live text by default; explicit outlines on Place remain available. Synthetic regressions; estimates unchanged. | | 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. | | 2026-10-18 | minor | Selected Mirror Repeat bounding-box handles take priority over the axis line; a Top-handle regression confirms the default repeat scales vertically, while center and end handles retain their move and rotation behavior. Headline scores and estimates unchanged. | @@ -278,6 +286,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-17 | minor | M12: maximal-disc pruning and junction merging, measured in the centre-lines class; whole-blob selection, scores and hours unchanged. | | 2026-10-17 | minor | M12: exact per-blob distances and Guo–Hall thinning preserve more stroke ends in the centre-lines class; whole-blob selection, scores and hours unchanged. | | 2026-10-17 | minor | M12: code-drawn centre-line scenes and metrics added to the ignored quality harness; current Create Strokes baseline in `image-trace-quality.md`. Tests only; scores, missing features and estimates unchanged. | +| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. | | 2026-10-16 | minor | Image Trace: a loop or junction-returning piece whose fit collapsed to one cubic keeps the fewest segments that hold its area and course instead of every positioned point, so a higher Paths no longer gives fewer segments on code-drawn noisy images (six falling runs to none; one fall with another cause remains). Scores and hours unchanged. | | 2026-10-16 | minor | Image Trace: a notch corner is cut only when the straight edges beside it lie in the shared piece, so both regions of the piece get the same points (code-drawn noisy colour images: 93 uncovered sample points to 0). Scores and hours unchanged. | | 2026-10-16 | minor | Image Trace: a crack's sub-pixel shift is the mean of the readings from its two sides, exactly antisymmetric, so abutting regions get the same points on thin slivers (a code-drawn noisy ripple went from 482–1,228 uncovered sample points per trace to 0). Scores and hours unchanged. | @@ -286,13 +295,13 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Correction to M12: quick Image Trace presets trace at once; the Image Trace button opens the adjustment controls (<https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html>). Wording only; status unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | -| 2026-10-15 | minor | Image Trace retains nonzero positioned boundaries when a fixed-end returning piece has fewer than two fitted cubics. Source-generated tests check survival, winding, shared-edge reversal and Noise controls; see `image-trace-quality.md`. Scores and hours unchanged. | | 2026-10-16 | minor | G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor (original approximations with their documented options); measured Photoshop filters 36/57 with Cutout, Neon Glow and Poster Edges, 21 remaining (19–31 h). | | 2026-10-16 | minor | G2: Effect › Sketch › Bas Relief, Charcoal, Photocopy and Stamp (#2527); measured Photoshop filters 37/57 with Cutout, Neon Glow and Poster Edges, 20 remaining (18–30 h). | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | -| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. | | 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged | | 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged | +| 2026-10-15 | minor | Image Trace retains nonzero positioned boundaries when a fixed-end returning piece has fewer than two fitted cubics. Source-generated tests check survival, winding, shared-edge reversal and Noise controls; see `image-trace-quality.md`. Scores and hours unchanged. | +| 2026-10-15 | minor | Artboard tool: closing a clean active tab restores the displayed board before Properties Delete. Nine targeted checks passed; three original close-route captures reviewed; scores and hours unchanged. | | 2026-10-14 | minor | Effect > Warp: style, orientation and percent controls; focused checks passed and original captures reviewed; scores and hours unchanged | | 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. | | 2026-10-14 | minor | MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged. | @@ -303,13 +312,12 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-14 | minor | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682); With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630); Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836); The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824); With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter); Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631); The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751); An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629); Redo during a drag keeps the drag as a step (#1856, #1719); Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter); Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809); The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634); Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738); Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735); macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855); Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767); Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744); Export for Screens and the Asset Export panel offer TIFF (#1632); SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870); DXF export writes the VPORT view direction once (#1789); Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695); Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717); Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683); A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694); krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875); milestone rows and estimates unchanged | | 2026-10-14 | minor | M12 row: each documented Image Trace preset has an enhanced version (#1847) | | 2026-10-14 | minor | Existing-guide Escape cancellation: eight app-input regressions, the Direct Selection Escape control and current full CI passed; scores and hours unchanged. | -| 2026-10-15 | minor | Artboard tool: closing a clean active tab restores the displayed board before Properties Delete. Nine targeted checks passed; three original close-route captures reviewed; scores and hours unchanged. | | 2026-10-14 | minor | Artboard tab changes align the tool with the displayed board; same-tab multiselection and Move/Scale Artwork options preserved. Three tab regressions and historical full CI on `a0671777` passed; scores and hours unchanged. | | 2026-10-14 | minor | Width-point finite-arithmetic follow-up: atomicity, history and native round-trip regressions and current full CI passed; scores and hours unchanged. | -| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | -| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | | 2026-10-14 | minor | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing); Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674); In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666); Escape cancels a Direct Selection drag (#1803, #1743); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); A filled Pencil path keeps a stroke of None (#1804, part of #1679); With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663); The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661); In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635); Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756); Select › Same matches type on its characters' fill and stroke (#1746, #1737); Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787); Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757); Slider rails show on Medium Dark (#1808, #1662); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation); Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648); EPS import: a stitched shading keeps its hard colour changes (#1654, #1653); WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774); Linked files that change during a drag are updated after the drag instead of being missed (#1773); Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780); One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779); A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775); Tests: text import fuzzing no longer searches installed fonts (#1785); Tests: render tests that add fonts no longer race the frame cache tests (#1795); `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748); MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727); MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728); The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739); Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px); milestone rows and estimates unchanged | | 2026-10-14 | minor | Malformed JSON app replies drop the MCP connection without replay and report uncertain completion (#1761); upstream notification and id-less refusal behavior preserved; scores and estimates unchanged. | +| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | +| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | | 2026-10-13 | minor | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620); A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604); A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613); Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570); Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590); The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560); Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616); Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't); The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score; Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571); Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575); Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558); Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578); `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592); Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615); Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607); The Polish translator confirmed the Polish rows the maintainers had added after the catalog was written (#1614); milestone rows and estimates unchanged | | 2026-10-13 | minor | PDF.R1: editable PDF import uses the shared pinned reader; layer and overprint scans preserve state across content windows and reject known incomplete input. Synthetic streaming, decoder-limit and dependency-source regressions added; scores and hours unchanged. | | 2026-10-13 | minor | PDF.W1: PDF export and print composition use the shared guarded writer; drawing, colour policy and editable import stay app-owned. Metadata regression coverage and immutable dependency checks added; scores and hours unchanged. | @@ -322,9 +330,9 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-13 | minor | M12 row: Image Trace has 15 presets and the Ignore Color, Shapes, Auto Grouping and Gradients options (#1574, #1609, #1618, #1627); its speed figure, measured before these, is dropped for the measurements in `image-trace-quality.md` | | 2026-10-13 | minor | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498); Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479); a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542); Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); a round corner next to a curved side curves smoothly instead of sticking out of the shape and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512); Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539); Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536); 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); estimates unchanged | | 2026-10-13 | minor | Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1508); commands and MCP `add_text` report area type that does not fit its frame (#1471), a tab stop in the Tabs panel drags from where the press landed (#1458); M9: 3D and Materials on outlined type and other shapes of many small curves (#1481), Twist 0° to 360° (#1524), twisted walls keep their faces (#1525); print-size images draw, export and crop (#1517), `vectorcraft-cli perf` reports load and memory on Windows (#1502); sliders in dialogs show their rail (#1483), a template layer stays in Preview in Outline view (#1486), French catalog complete again (#1511), a Hungarian message in the informal address (#1474); `graph.setData`'s CSV keeps each value exactly (#1485); estimates unchanged | +| 2026-10-13 | minor | M6: Split Into Grid has its documented options (#1462), the Pathfinder panel works on a group selected alone (#1450); M2: Transform Objects in the Transform dialogs and Transform Each, Reflect across an angle (#1448, #1460); M4: grayscale TIFFs with an alpha channel or extra channels place (#1466 in part), strokes kept apart from a faded or masked fill on PDF and `.ai` import (#1434), SVG knockout groups without masks (#1435); M7: Fit Headline in a frame one line tall (#1444), frames beyond a billion points hold no text (#1451); M13: commands refuse a parameter key they don't take (#1442); M5: edits and Alt-drags of thousands of objects (#1429 in part); M14: a focused field follows its value (#1457), selected text in value fields is readable (#1465 in part), a window surface past 8192 px no longer closes the app (#1459 in part); estimates unchanged | | 2026-10-12 | minor | Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged | | 2026-10-12 | minor | Exchange with Illustrator users: Save As `.ai` › Version: Legacy (version 7) keeps layers, groups and type as text, one artboard per file (#868); estimate unchanged until users test it | -| 2026-10-13 | minor | M6: Split Into Grid has its documented options (#1462), the Pathfinder panel works on a group selected alone (#1450); M2: Transform Objects in the Transform dialogs and Transform Each, Reflect across an angle (#1448, #1460); M4: grayscale TIFFs with an alpha channel or extra channels place (#1466 in part), strokes kept apart from a faded or masked fill on PDF and `.ai` import (#1434), SVG knockout groups without masks (#1435); M7: Fit Headline in a frame one line tall (#1444), frames beyond a billion points hold no text (#1451); M13: commands refuse a parameter key they don't take (#1442); M5: edits and Alt-drags of thousands of objects (#1429 in part); M14: a focused field follows its value (#1457), selected text in value fields is readable (#1465 in part), a window surface past 8192 px no longer closes the app (#1459 in part); estimates unchanged | | 2026-10-12 | minor | TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged | | 2026-10-12 | minor | Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged | | 2026-10-12 | minor | M9: Extrude & Bevel, Inflate and Rotate (initial 3D types, [three-d.md](three-d.md)) | @@ -335,7 +343,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-12 | minor | M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged | | 2026-10-12 | minor | Pathfinder single-result booleans preserve source names and SVG IDs (#1293); paint-order, grouped/compound sources, unchanged geometry/appearance and Undo/Redo regression tests; scores and estimates unchanged | | 2026-10-12 | minor | DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves stay open and curves retained; rational/non-rational import and export versions regression-tested; scores/hours unchanged | -| 2026-10-11 | minor | Czech render-site follow-up: file-dialog filters, diagnostics, preset numbering, contributor tooltips/plurals, gradient grammar and contextual printer mark styles; estimates unchanged | | 2026-10-12 | minor | Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged | | 2026-10-12 | minor | Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged | | 2026-10-12 | minor | M6: Simplify preview/counts and valid selection after topology changes (#1272); auto/slider/advanced UI remains open; estimates unchanged | @@ -349,26 +356,23 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-12 | minor | M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged | | 2026-10-12 | minor | M2: independent segment selection/display and geometry-preserving Delete (#1278); estimates unchanged | | 2026-10-12 | minor | M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged | +| 2026-10-12 | minor | M14: native macOS save Format menu (#866); filename regression tests, actual SVG save and PDF selection/cancel verified; estimates unchanged | +| 2026-10-12 | minor | M6: compound paths inherit the backmost appearance; selected members read and change the compound fill rule (#1197); estimates unchanged | +| 2026-10-12 | minor | M6: Divide accepts open cutting paths (#1307), preserving filled-face appearance and Undo; the existing segment cap remains; estimates unchanged | +| 2026-10-12 | minor | M14: numeric-field focus and text-drag selection regression-tested (#1326, in part); estimates unchanged; short movements below the click threshold remain open | +| 2026-10-11 | minor | Czech render-site follow-up: file-dialog filters, diagnostics, preset numbering, contributor tooltips/plurals, gradient grammar and contextual printer mark styles; estimates unchanged | | 2026-10-11 | minor | M12: the Graph Data worksheet. Scores/hours unchanged. | | 2026-10-11 | minor | M12: graph label formatting kept through regeneration. Scores/hours unchanged. | | 2026-10-11 | minor | M12: Graph Type's widths past 100%, First Row/Column in Front, Draw Filled Lines. Scores/hours unchanged. | | 2026-10-11 | minor | M12: Graph Type's Add Drop Shadow and Add Legend Across Top. Scores/hours unchanged. | | 2026-10-11 | minor | M12: Graph Data's Transpose and Switch x/y, `|` line breaks in graph labels, pie Legend/Position/Sort. Scores/hours unchanged. | | 2026-10-11 | minor | M12: Object › Graph › Design… and Marker… (graph designs, marker designs); column designs remain. Scores/hours unchanged. | -| 2026-10-12 | minor | M14: native macOS save Format menu (#866); filename regression tests, actual SVG save and PDF selection/cancel verified; estimates unchanged | -| 2026-10-12 | minor | M6: compound paths inherit the backmost appearance; selected members read and change the compound fill rule (#1197); estimates unchanged | -| 2026-10-12 | minor | M6: Divide accepts open cutting paths (#1307), preserving filled-face appearance and Undo; the existing segment cap remains; estimates unchanged | -| 2026-10-12 | minor | M14: numeric-field focus and text-drag selection regression-tested (#1326, in part); estimates unchanged; short movements below the click threshold remain open | | 2026-10-11 | minor | M9: PDF raster-effect resolution-cap warnings verified with real capped/uncapped exports; scores and hours unchanged | | 2026-10-11 | minor | #893 (Shape Builder seams on grids of lines) fixed; G11 keeps #937 and its 8–15 h | | 2026-10-10 | minor | M7: document-point character sizes, leading and baseline shift, and resettable horizontal stretching after object/group scaling (#1034). Optional group font-size preservation remains separate; scores/hours unchanged. | | 2026-10-10 | minor | M9 row from main: initial Revolve | | 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha | | 2026-10-10 | major | Created from ROADMAP.md's milestone table and "Where we're going"; added Current focus and the beta list | -| 2026-10-28 | minor | Flare ray rotation follows vertical travel at one degree per document point, independent of pointer event count. Shift freezes the current orientation; Space also freezes rotation on the first update. This is the chosen application interaction rate. Scores and estimates unchanged. | -| 2026-10-28 | minor | Drop Shadow Darkness subtracts the normalized amount from RGB source channels or adds it to CMYK/Gray black before profile conversion and blur; near-100% becomes gray black, with shadow opacity separate. SVG keeps live filters for uniform sRGB sources and renders shadows for other RGB profiles, source models and gradients. Gradient-stop adjustment is an application rule; spot/custom colors and composite-source parity remain open. Scores and estimates unchanged. | -| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | -| 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | > **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0fd0586a04..90ae8c9bbc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,8 @@ # 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 (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) +> +> 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). > @@ -49,26 +51,24 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: 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, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | | 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | -| 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | -| 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, 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; evidence added. 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-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | +| 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | +| 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, 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; evidence added. 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 | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged | | 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). The other corners' widgets then hide: a Shift-click on another corner's anchor adds that corner and shows its widget, and a Shift-click on a showing widget deselects its corner. Adding a corner by Shift-clicking its widget while it is hidden stays open (#1232). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | | 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). Once some corners are picked, Shift-click still adds or removes one. Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | -| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | -| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; scores and estimates unchanged | | 2026-11-07 | minor | Stroke gradients along or across a variable-width stroke: where the stroke crosses or runs close by itself, a narrow part paints over a wide part only where the two overlap, instead of in a square as wide as the stroke gets anywhere (#5960). Each slice reaches as far as the stroke is wide there (for a dashed stroke, wherever a cap or dot can reach, found in one pass along the path however densely it is flattened). Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone`, `widest_ahead_is_the_widest_within_the_window`. 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-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-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-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. | | 2026-11-05 | minor | Moving a large selection reuses the object locations found during command validation. A 20,000-object nudge took 58.07 ms before and 51.26 ms after in paired median runs; object positions, selection, and undo behavior stay the same. Scores and estimates unchanged. | | 2026-11-05 | minor | Canvas: an image drawn larger than 1,024 pixels at the size it shows, with its colours made again (separated into ink planes, colorized or shown through Separations Preview), has them made only for the 1,024-pixel tiles in view, each kept for later frames (#4155). Zooming in to 50%, 100% and 200% on the file of #4155 went from 8.6–9.0 s to 0.78 s, 0.05 s and 0.05 s (measured, M3 Pro, one run each), with the same pixels. Scores and estimates unchanged. | @@ -84,40 +84,42 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-05 | minor | Layers panel: a layer whose Layer Options › Print is off shows its name italic, as the reference application shows non-printing layers, and keeps it while the row is dragged (#5739); a printing layer's name is upright, and Print on again makes it upright again; scores and estimates unchanged. | | 2026-11-05 | minor | G12: Hyphenation › Default Language sets the language of new type unless Document Setup chose one, stored as a tag; each run hyphenates in its own language, English only so far, so 2 Preferences options stay unused (#394) | | 2026-11-05 | minor | Type Tools add-on: Release Type on a Path (a VectorCraft addition, #5532): evidence added; scores unchanged. | -| 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… and `addon.pathtools.cornerSet` `minAngle`/`maxAngle` round only the corners whose angle is in a range (#5545); evidence added; scores unchanged. | -| 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… and `addon.pathtools.cornerSet` `every` round all, the odd-numbered or the even-numbered anchors of each path (#5546); evidence added; scores unchanged. | -| 2026-11-04 | minor | Transform Each's Scale has the documented Relative and Absolute radio buttons (`object.transformEach` `relative`: percentages of each object's size, or the width and height it becomes, measured on each object's own bounds) and the link that keeps the two scales in proportion; the panel lists Scale, Move, Rotate, the Options in two columns, the Reference Point and Preview (level with Copy, Cancel and OK) in the documented order. Scores and estimates unchanged. | | 2026-11-05 | minor | PDF import: a DeviceN gradient of a spot ink over process plates keeps the ink and the plates, and PDF export writes it as a DeviceN shading again (#3235); scores unchanged | | 2026-11-05 | minor | Optical kerning moves each pair part of the way toward the font's own stem spacing, with a minimum gap and tighter limits, and leaves ideographs, kana, fullwidth forms and dashes on the font's spacing, so text is no longer squeezed (#5336). Scores and estimates unchanged. | | 2026-11-05 | minor | Gradient panel: Delete Stop sits at the right end of the ramp, so it shows whenever the ramp does instead of under the stop fields, where a short panel hid it (#5403); scores and estimates unchanged | +| 2026-11-05 | minor | Floating panels keep out of the main window's way: hidden under a modal dialog, and out of the top level while a menu is open (#5238); scores and estimates unchanged. | +| 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… and `addon.pathtools.cornerSet` `minAngle`/`maxAngle` round only the corners whose angle is in a range (#5545); evidence added; scores unchanged. | +| 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… and `addon.pathtools.cornerSet` `every` round all, the odd-numbered or the even-numbered anchors of each path (#5546); evidence added; scores unchanged. | +| 2026-11-04 | minor | Transform Each's Scale has the documented Relative and Absolute radio buttons (`object.transformEach` `relative`: percentages of each object's size, or the width and height it becomes, measured on each object's own bounds) and the link that keeps the two scales in proportion; the panel lists Scale, Move, Rotate, the Options in two columns, the Reference Point and Preview (level with Copy, Cancel and OK) in the documented order. Scores and estimates unchanged. | | 2026-11-04 | minor | Record Select Text by Font's artboard and layer scope (#5529, a VectorCraft addition); scores and estimates unchanged. | -| 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-04 | minor | Type core: a Type-range Copy or Cut publishes styled art alongside plain text, so canvas Paste keeps font family, size and color after editing ends (#5636); scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | Raster/PSD defaults honor document resolution; CLI run/convert accept --ppi, matching MCP options.ppi precedence. Synthetic regressions added; scores and hours unchanged. | -| 2026-11-05 | minor | Floating panels keep out of the main window's way: hidden under a modal dialog, and out of the top level while a menu is open (#5238); scores and estimates unchanged. | | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset, as the public page lists the pane's options for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units (#2078). Scores and estimates unchanged. | | 2026-11-04 | minor | Document Tools add-on (a VectorCraft addition): Batch Trace Folder under Object › Plug-ins (`addon.document.batchTraceFolder`, #4921); evidence added; scores unchanged. | | 2026-11-04 | minor | Object › Expand turns selected symbol instances into their art as Break Link places it, one level deep, with the Expand dialog's Fill and Stroke reaching that art. Scores and estimates unchanged. | +| 2026-11-04 | minor | Record Find Font's replacement rows: family and style together, a search over them, and a scroll state of their own (#4972); scores and estimates unchanged. | +| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | 2026-11-03 | minor | The OpenType panel offers the selected font's stylistic sets by custom or numbered name, and allows several to be active. Scores and estimates unchanged. | | 2026-11-03 | minor | Actions panel: an expanded action's steps can be excluded from playback and Batch… by clearing their checkmarks; action and set checkmarks include or exclude all their steps, and exclusions survive restart, duplication and `.vcactions` save/load. Scores and estimates unchanged. | | 2026-11-03 | minor | Correct the Type core missing list and remove duplicate target and Type core entries: Hyphenation and Justification options are complete; the remaining Every-line Composer fidelity limits stay listed. Record the Kashidas paragraph lengths and per-character switch in the feature history (#5404). Scores and estimates unchanged. | | 2026-11-03 | minor | Correct the Type core missing list: Hyphenation and Justification options are complete; the remaining Every-line Composer fidelity limits stay listed. Record the Kashidas paragraph lengths and per-character switch in the feature history (#5404). Scores and estimates unchanged. | | 2026-11-03 | minor | Ligature caret stops use GDEF contour-point positions in horizontal and vertical type at the font's default variation location; invalid point indices keep the evenly divided fallback. Scores and estimates unchanged. | -| 2026-11-04 | minor | Record Find Font's replacement rows: family and style together, a search over them, and a scroll state of their own (#4972); scores and estimates unchanged. | | 2026-11-03 | minor | Spiral decay applies once per full wind of four segments; newly drawn spirals with the same settings wind more broadly than earlier builds. Existing paths are unchanged. Scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Type Tools add-on: Text Tweaker (a VectorCraft addition, #4924): evidence added; scores unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | | 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | -| 2026-11-02 | minor | Legacy `.ai` saves (version 7) write type with rotated characters as outlines that keep each character's rotation, with a warning unless `outlineText` is on (#5186); scores unchanged. | | 2026-11-03 | minor | Record shipped Find Font replacement sources: Document, Recent and System (#5217); scores and estimates unchanged. | -| 2026-10-31 | minor | Object › Path › Smooth smooths a path along its length before refitting it, so Auto-Smooth takes a shaky line to one smooth curve instead of leaving its jags, and with anchor points direct-selected it smooths only that section; scores unchanged. | | 2026-11-03 | minor | Enclosing Lasso adds freehand whole-object enclosure selection under Object › Plug-ins; scores and estimates unchanged. | -| 2026-10-31 | minor | New Document: More Settings has the Save Preset button beside the name too, saving its settings (artboard layout included) as a preset (#4893); scores unchanged. | | 2026-11-03 | minor | Touch Type moves right-to-left characters along their line when a preceding logical character is on the same line; direction rules are VectorCraft's choices. Scores and estimates unchanged. | -| 2026-10-31 | minor | Object › Rasterize in a CMYK document with its CMYK colour model makes a CMYK image of the art's ink amounts (as CMYK exports draw them), so the Properties panel says CMYK and CMYK exports keep its inks; a transparent background becomes the image's opacity mask (#4882); scores unchanged. | | 2026-11-03 | minor | Composition spacing stays along the line under Character Rotation, including the turning point and unrotated placement; scores and estimates unchanged. | +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | +| 2026-11-02 | minor | Legacy `.ai` saves (version 7) write type with rotated characters as outlines that keep each character's rotation, with a warning unless `outlineText` is on (#5186); scores unchanged. | +| 2026-10-31 | minor | Object › Path › Smooth smooths a path along its length before refitting it, so Auto-Smooth takes a shaky line to one smooth curve instead of leaving its jags, and with anchor points direct-selected it smooths only that section; scores unchanged. | +| 2026-10-31 | minor | New Document: More Settings has the Save Preset button beside the name too, saving its settings (artboard layout included) as a preset (#4893); scores unchanged. | +| 2026-10-31 | minor | Object › Rasterize in a CMYK document with its CMYK colour model makes a CMYK image of the art's ink amounts (as CMYK exports draw them), so the Properties panel says CMYK and CMYK exports keep its inks; a transparent background becomes the image's opacity mask (#4882); scores unchanged. | | 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | | 2026-10-31 | minor | Export for Screens is laid out as the reference app's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | | 2026-10-31 | minor | Artwork Cleanup add-on: Join Point Text (`addon.cleanup.joinText`) joins stacked one-line point type into one object, a paragraph per line keeping its baseline (#4826); scores unchanged. | @@ -220,7 +222,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 (seven generated libraries including Bristle Brushes, Game Trails and Energy Beams, 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 (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) | | 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`) | @@ -240,14 +242,14 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); DXF export's Include Placed Images and Include Fill Hatches options, off in CNC / CAM (`images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`; score, gaps and hours unchanged); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_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 (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated; MCP `add_text` exposes existing vertical type; score and hours unchanged | -| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged; icon-strip flyouts retain user sizes (#5713); floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), scores and hours unchanged ; interim macOS cursor delivery (#2292), headless coverage only; native validation pending, score and hours unchanged | +| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326); a focus click released more than 1 pt from its press keeps the caret and a tap within 1 pt selects all (#6659); artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged; icon-strip flyouts retain user sizes (#5713); floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), scores and hours unchanged ; interim macOS cursor delivery (#2292), headless coverage only; native validation pending, score and hours unchanged | | Libraries, Links, Package | 2 | 82% | shared libraries, linked graphics, gradients and patterns as library items (no cloud by design) | 2–3 | estimated | | Generative AI (Text to Vector, Generative Recolor, Shape Fill, Concept to Vector, Prompt to Edit, Rewrite Text) | 2 | 0% | a pluggable provider and the panels; needs an owner decision on models | 30–60 | measured (absent) | | **Feature depth** | **105** | **~75%** | | **~250–420** | | @@ -489,7 +491,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 (seven generated in code including Bristle Brushes, Game Trails and Energy Beams 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 (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 | | 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 | @@ -541,7 +543,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 seven (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) 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 eight (Art Ribbons, 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 | @@ -553,6 +555,8 @@ since 2026-10-01. ## Evidence: what ships today, by area +- **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, suggesting the original name and format, preserving options for the unchanged format and opening the chosen format’s Save options on a change. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; focused UI regressions passed; engine regression execution and native panel acceptance pending. Scores and hours unchanged. + - **PDF authored replacement text (#6165):** `crates/pdf/src/tests/actual_text.rs` covers replacement once with suffix placement, PDF text-string decoding, empty and nested owners, sibling spans, page reset, multiple text-show operations, style/line changes, fill-and-stroke, reader windows and bounded input. Authored logical-order stretches are protected while adjacent ordinary text still undergoes reconstruction. The replacement takes its first placed glyph's style and line; its characters are not redistributed over later lines/styles. Exact mixed-direction paragraph layout is not claimed. No export changes; scores and hours stay at 75% / 10–20 h. @@ -591,16 +595,17 @@ since 2026-10-01. - **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. - **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. - **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`. - **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. -- **Clicks pass through raster effects:** clicks with the Selection and Direct Selection tools, and the Selection tool's pointer and marquee, leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. +- **Selection passes through raster effects:** clicks with the Selection, Direct Selection and Group Selection tools (Group Selection's repeated click included), the Selection tool's pointer and marquee, Direct Selection's marquee, pointer and anchor highlight, and Group Selection's marquee (E for enclosed objects included) leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, a repeated Group Selection click there adds the groups of the object under it, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). Over a shadow, Direct Selection highlights only the anchors of the object under it, and Group Selection keeps its own pointer and highlights no anchor, as it does everywhere (VectorCraft's own rule). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. - **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. @@ -647,6 +652,7 @@ 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. @@ -764,8 +770,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`. - `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`). + - 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`. - 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`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). @@ -784,7 +792,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. - Window › SVG Interactivity attaches JavaScript to the selected objects' events (`onclick`, `onmouseover` and 17 more) and links JavaScript files to the document (Link JavaScript Files…); SVG export writes the events as event attributes and the files as `<script xlink:href>`, SVG import reads both back as text without running them, and the native format keeps them (`svgInteractivity.*`). - - Numeric fields keep the pointer's pressed character as the text-drag start when movement exceeds the click threshold (#1326, in part); a completed focus click selects the unit too. Pixel/point, plain and optional fields are regression-tested in both directions, before/after focus, with replacement text; keyboard focus, double-click, arrows and wheel keep their prior behavior. Native egui input confirms partial replacement gives 17 px instead of 700 px, one Undo/Redo, window captures and an exact saved-document read in a separate CLI process. Short movements still counted as clicks can select all; that case remains open. + - Numeric fields keep the pointer's pressed character as the text-drag start when movement exceeds the click threshold (#1326); a completed focus click selects the unit too. Pixel/point, plain and optional fields are regression-tested in both directions, before/after focus, with replacement text; keyboard focus, double-click, arrows and wheel keep their prior behavior. Native egui input confirms partial replacement gives 17 px instead of 700 px, one Undo/Redo, window captures and an exact saved-document read in a separate CLI process. A focus click released more than 1 pt from its press keeps the caret, and a tap within 1 pt still selects all (#6659; short-drag and jitter-tap regressions in `tests/numfields.rs`). - Unit fields and length strings take `%` in arithmetic: `3cm*50%` reads as 1.5 cm, and `50pt+25%` reads as 62.5 pt. - The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's variants for other tools (only the selection tools and the Artboard tool show it). - A click on another object moves the contextual task bar under it without hiding it for the press; it gives way only to a drag (#4681). @@ -852,8 +860,9 @@ 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) 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, 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`. - **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. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. @@ -867,6 +876,7 @@ 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. @@ -948,7 +958,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. - Select ▸ Object ▸ Not Aligned to Pixel Grid (`select.object.notAlignedToPixelGrid`) selects the paths and compound paths whose pixel-aligned property is off, whatever their coordinates, and leaves out guides, type, images, groups and the paths inside a compound path ([help page](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185); `tests::pixelalign::not_aligned_to_pixel_grid_selects_paths_without_the_property`, `not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected`). + Select ▸ Object ▸ Not Aligned to Pixel Grid (`select.object.notAlignedToPixelGrid`) selects the paths and compound paths whose pixel-aligned property is off, whatever their coordinates, and leaves out guides, type, images, groups and the paths inside a compound path ([help page](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185); `tests::pixelalign::not_aligned_to_pixel_grid_selects_paths_without_the_property`, `not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected`). It also leaves out symbol instances, area type, type on a path, gradient meshes and placed documents, and the paths on template layers, in hidden or locked sublayers and on locked artboards; it selects the paths in clip groups, blends, envelopes, repeats and Intertwine groups and paths with effects (`not_aligned_to_pixel_grid_leaves_out_images_symbol_instances_and_type`, `not_aligned_to_pixel_grid_leaves_out_template_layers_locked_sublayers_and_locked_artboards`, `not_aligned_to_pixel_grid_selects_clipping_paths_and_paths_with_effects`, `not_aligned_to_pixel_grid_selects_the_paths_of_blends_envelopes_repeats_and_intertwines`, `not_aligned_to_pixel_grid_leaves_out_meshes_placed_documents_and_guides_but_takes_live_shapes`, `not_aligned_to_pixel_grid_takes_a_compound_path_by_its_own_property`). Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself. Align to Glyph Bounds (#4231): the Align panel menu's Align to Glyph Bounds submenu has Point Text and Area Text, each checked while on (`alignGlyphBoundsPoint`, `alignGlyphBoundsArea`, off by default). With one on, Align, Distribute Objects and Distribute Spacing measure that kind of type by the ink of its glyphs instead of its em box (point type) or frame (area type), so lowercase type aligned to a rectangle's top lines up the tops of its letters; the other kind, type on a path and type in a selected group are measured as before, and Use Preview Bounds adds the characters' strokes ([Align text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/align-text.html); `tests::glyphbounds`, `panels::align::tests::align_to_glyph_bounds_toggles_point_and_area_text`). The Character and Properties panels' controls for the same setting are not done. Anchor Align moves the selected points to their bounds or the artboard (#1180). Repeating it, or moving by at most 1e-9 pt on both axes as whole-object Align does, leaves the document, Undo, Redo and saved state unchanged; actual moves still take one undo step. `crates/engine/src/tests.rs` covers repeated alignment, rounding-only alignment, unchanged handles, saved state, command recording and Undo/Redo. @@ -1172,7 +1182,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. @@ -1193,9 +1204,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency leave art without transparency as it is when it overlaps only other art without transparency, unless it overlaps a member of a see-through object's group and lies above that member and below the group's topmost object. Such art joins the group, and the group takes the place of its topmost object, so the stacking order stays the same. In files without transparency with Clip Complex Regions on, art under the group within one image pixel of a member joins it too. The 1 pt antialiasing margin goes around a group's art once, however deeply its groups nest. On a grid of 3,000 rectangles with a 0.5 pt stroke, 60 of them with a drop shadow, the Flattener Preview counts 180 affected objects where it counted 3,000, and Flatten Transparency with the Medium preset makes 240 flat regions and 2 images where it made one image of the whole grid. These rules are VectorCraft's own (`tests::flatten::opaque_art_that_touches_only_opaque_art_stays`, `tests::flatten::opaque_art_the_group_would_move_under_joins_it`). New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. + Type › Type on a Path › Center on Top and Center on Bottom (`type.pathCenter.top`, `type.pathCenter.bottom`, also on the Contextual Task Bar for type on a closed path) slide type on a closed path so the middle of its span lands on the path's topmost or bottommost point as drawn, keep the span's length, and set the first paragraph to Center unless it is Center or Justify Center. This is VectorCraft's own behavior (`crates/geom/src/arc.rs`, `crates/engine/src/tests/pathtype.rs`). Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: reshaping or scaling the path fits the text again; one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off, threading the type or Release Type on a Path keeps the drawn sizes; SVG, EPS, legacy `.ai` and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, threaded frames cannot use it, and Convert To Point Type turns it off, keeping the sizes (`crates/engine/src/cmd/typecmd/block_tests.rs`). + Dynamic Text on point type (Type › Dynamic Text, the Contextual Task Bar; [Adjust formatting and resize text](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html) applies it to existing text) turns it into area type with Block on. VectorCraft's own rules: the frame is as wide as the widest line with its indents, so that line keeps its size and place across the page, and it grows in depth to hold every fitted line (`crates/engine/src/cmd/typecmd/block_tests.rs`). Type › Dynamic Text Presets › Circle, Arch and Bow (`type.dynamicTextPreset`) put point type or type on a path on a circular, upward-curving or downward-curving path with Dynamic Text on, with the text's position against the path (above, center, below) and its direction ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)). VectorCraft's own rules: the circle is as wide as the text and the text starts at its bottom; Arch and Bow are a 120° arc whose chord is as wide as the text; above, center and below are Align to Path's Descender, Center and Ascender (`crates/engine/src/tests/dynamic_text.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. @@ -1237,6 +1250,7 @@ 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, 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. @@ -1258,6 +1272,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)). @@ -1293,13 +1308,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`. With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. - Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Select/Move widget and Detach are missing. + Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Detach is missing. Objects on Path Space widget: with one Objects on Path group of two or more objects selected, the Selection and Objects on Path tools show a square handle beyond its last object; dragging it along the path, round its bends, sets the distance between the objects' attachment points in one undo step, the first object staying where it is, and with Alt (Option on macOS) the objects spread from both ends toward the center; the distance shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The group keeps the distance (`object.objectsOnPath.options {spacing, start}`) through Pivot and Rotate changes, path edits, copies, saving and reopening, and scales it with the group; by default (`"auto"`) the objects are spread evenly as before. Evidence: `objects_on_path::tests::{space_sets_the_distance_between_the_stops, space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object}` in `crates/doc`, `tests::objects_on_path::space_sets_the_distance_between_the_objects_in_one_undo_step` in `crates/engine`, `objects_on_path::tests::dragging_the_space_widget_spaces_the_objects_from_the_first_one` in `crates/tools`. Objects on Path Move All widget: with one Objects on Path group selected whose objects have room to move, the Selection and Objects on Path tools show a diamond below its first object; dragging it along the path, round its bends, moves every object along the path together in one undo step, keeping the distances between them (`object.objectsOnPath.options {start}`). On an open path the widget shows only when the distance from the first object to the last is shorter than the path, so not for objects spread evenly from end to end, and the objects stop at the path's ends; on a closed path they go on round across its first anchor. The first object's distance along the path shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::{dragging_the_move_all_widget_moves_every_object_along_the_path, a_move_all_drag_on_a_closed_path_goes_round_across_the_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room` in `crates/ui-egui`. + Objects on Path Select/Move widget: with one Objects on Path group selected, the Selection and Objects on Path tools show a small ring on each object's attachment point, highlighted under the pointer. A click on one chooses that object and fills its ring blue while the group stays selected (`object.objectsOnPath.selectMember`); a click on the object's art still selects the group. Dragging a ring along the path moves its object to the object's place nearest the pointer, and the objects between move one place toward where it was, in one undo step (`object.objectsOnPath.moveMember`); hidden and locked objects keep their places; only the order along the path changes, not the Layers panel's order or the Space. On an open path the drag stops at the ends, on a closed path it goes on round across the first anchor, and Escape cancels it ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::moving_an_object_shifts_the_ones_between_it_and_its_new_place` in `crates/doc`, `tests::objects_on_path::move_member_takes_the_target_s_place_and_shifts_the_objects_between` in `crates/engine`, `objects_on_path::tests::{dragging_a_select_move_widget_moves_its_object_to_the_nearest_place, a_select_move_drag_goes_round_a_closed_path_across_its_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_a_select_move_widget_per_object` in `crates/ui-egui`. Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, seven gradient libraries (among them Organic Fades, soft perceptually mixed multi-stop fades that thin out to transparent, and Holographic, seven opaque linear foils of narrow pastel and pearl bands and seven radial glows that fade out to a clear edge) and two pattern libraries (Geometric Patterns, and Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, seven gradient libraries (among them Organic Fades, soft perceptually mixed multi-stop fades that thin out to transparent, and Holographic, seven opaque linear foils of narrow pastel and pearl bands and seven radial glows that fade out to a clear edge) and three pattern libraries (Geometric Patterns; Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; and Optical Effects with eight of VectorCraft's own seamless wave, ring, warped-check, stripe, chevron and lens tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([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"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. @@ -1331,23 +1347,24 @@ 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). @@ -1357,6 +1374,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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 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. @@ -1538,7 +1556,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests/place.rs`). - Separations Preview shows placed and embedded raster images on the visible plates, as it shows vector art (#4153): an RGB image's pixels are separated into process inks with the proof setup's intent, a CMYK image shows its own inks, a hidden plate's ink leaves the image, and with one plate visible the image shows that plate's ink as a grey. Raster images carry no spot ink, so a spot plate shown alone shows them as paper; a colorized image shows its color's plates. Emoji and other bitmap glyphs follow the same rule. The plate-filtered pixels and their reduced-size copies are kept apart from the image's own pixels, for each set of visible plates, proof setup and color settings, and with the preview off nothing changes. Evidence: `tests::colorize::separations_preview_shows_an_rgb_images_process_inks`, `separations_preview_shows_a_cmyk_images_own_inks`, `separations_preview_keeps_an_images_pixels_per_plate_set`. Print preview fidelity remains the open gap, so scores and hours are unchanged. - Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests/print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged. - - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). + - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root `<title>`. The title is File Info's alone (`DocMetadata::title`): the document's name stays the file's name with its extension, a file that gives no title leaves File Info with that name, and exports (PDF, SVG, PNG, EPS, `.ai`) and native saves carry it; changing the title in File Info sets the document's name as before (#3626). - PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests/fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/marks.rs`). - PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). - Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`). @@ -1553,11 +1571,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before. - On the desktop, Paste from a menu or shortcut captures another app's bitmap or reads a copied image file on the UI thread, then decodes the image on the file-reading worker. The status bar shows "Pasting" until the image is pasted. Windows device-independent bitmaps and Linux clipboard images are encoded as PNG on that worker. A Copy or Cut made while the image is read keeps its internal clipboard. `edit.paste*` over MCP and scripts reads the bitmap at once. - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). + - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, 8 bits each, as a CMYK TIFF. - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place <name>: the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). - 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`. @@ -1581,7 +1600,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. @@ -1722,6 +1741,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. @@ -1969,6 +1989,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. @@ -2069,16 +2093,25 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 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. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | -| 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | | 2026-11-11 | minor | Bundled content: built-in Status Effects graphic style library; style libraries evidence updated; ~40% → ~42%; MCP Symbol libraries list UI Frames and Map Markers; more built-in graphic-style libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem); symbol libraries evidence updated; ~38% → ~40%; more built-in symbol libraries remain. | +| 2026-11-11 | minor | Guides and snapping: a Shift move slides the selection along its line onto a ruler guide by its edges and centre, as a move without Shift does (#4312); a guide a nearly parallel line only reaches a long way off pulls nothing. Score and hours unchanged (estimated). | +| 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | | 2026-11-10 | minor | Type advanced: the Edit Custom Dictionary dialog, #4936; evidence added; the Check Spelling dialog remains; scores and hours unchanged. | | 2026-11-10 | minor | File formats: in a `.ai` or EPS opened from its editing data, an envelope of type opens live where it draws as its drawn art and the layers with it draw as the page (the envelope weighed as the art it draws); elsewhere, and where a text or visibility variable binds its type, it keeps its drawn art, with a note (#3179); evidence updated; scores and hours unchanged. | | 2026-11-10 | minor | Bundled content: built-in Game HUD graphic style library; style libraries evidence updated; ~34% → ~36%; more built-in graphic-style libraries remain. | @@ -2086,6 +2119,8 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-11-10 | minor | Type advanced: Spelling › Auto Spell Check in the context menu of type, checked while it is on, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Raster effects: the Effect Gallery shows a thumbnail of each filter and a preview of the visible stack (or the filter being browsed) inside the dialog, both an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); 52% → 55%, 1–2 h → none (estimated). The canvas preview and one-undo OK are unchanged. Headline numbers unchanged. | +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | +| 2026-11-10 | minor | Vector Halftone Preview leaves the dots unobscured and keeps the selection box (#6369). Scores and estimates unchanged. | | 2026-11-09 | minor | Canvas: a 3D effect changed on type or a group redraws where its solid was, measured by the effect's art through the renderer that drew the frame; exports and Object › Rasterize take the solid of 3D type; Fit to Artwork Bounds and per-artboard SVG export measure all artboards through one renderer; a 3D effect on a fill or stroke that builds nothing no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports (evidence in Feature revisions). Scores and estimates unchanged. | | 2026-11-09 | minor | Bundled content: built-in Game Trails brush library (Speed Line, Dash Trail, Burst Ray, Zigzag); brush libraries evidence updated; ~32% → ~34%; more built-in brush libraries remain. | | 2026-11-09 | minor | Type advanced: Auto Spell Check (`edit.autoSpellCheck`), #4936; evidence added; the dialogs and the right-click Spelling menu remain; scores and hours unchanged. | @@ -2099,14 +2134,13 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-11-09 | minor | File formats: EPS import: 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. | | 2026-11-08 | minor | Type advanced: spell check engine, `text.spelling.check` lists unknown words per run language at `text.editRange` byte offsets and `text.spelling.suggest` gives suggestions, from craft-proof (#4936); evidence added; the dialog, Auto Spell Check and the custom dictionary remain; scores and hours unchanged. | | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | +| 2026-11-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. | +| 2026-11-08 | minor | Consolidated the four current Guides feature-area rows using existing global/artboard ruler mode, video ruler and status-bar evidence; remaining gaps, score and estimates unchanged. The earlier detailed missing-items table remains historical. | | 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type keeps its paragraphs' tab stops, and 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); evidence added; scores unchanged. | | 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out closer to the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, possibly more of the last lines the file sets in an area frame of its own, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | -| 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. | -| 2026-11-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. | -| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | -| 2026-11-08 | minor | Consolidated the four current Guides feature-area rows using existing global/artboard ruler mode, video ruler and status-bar evidence; remaining gaps, score and estimates unchanged. The earlier detailed missing-items table remains historical. | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | +| 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. | @@ -2116,24 +2150,23 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-11-06 | minor | Properties in Edit Artboards: a Transform section (reference point, X, Y, W, H, preset, orientation, Move and Scale Artwork) above the Artboards section (name, Artboard Options, Fill), X and Y of the shared reference point (#5219); evidence updated; scores unchanged. | | 2026-11-06 | minor | Scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields; a click still opens the panel (#4088); evidence updated; scores unchanged. | | 2026-11-06 | minor | Control bar: Paragraph: link and Align left/center/right while type is selected, trimmed from the bar's right end after X/Y/W/H and the Align buttons (#5372); evidence added; scores unchanged. | -| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | -| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | -| 2026-11-05 | minor | View & navigation: document tabs are reordered by dragging them along the tab strip (`document.move`, #5663); evidence added; scores unchanged. | -| 2026-10-30 | minor | Effects: Color Halftone's Max Radius is 4–127 px of the Document Raster Effects resolution, shown in px whatever the units; older documents open converted, and files saved for older app versions hold points (#3260); scores unchanged. | -| 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): Object › Plug-ins › Compound Shapes lists Make, Release and Expand Compound Shape, and holds a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons make a compound shape on a click and give the permanent result on Alt-click (the default is the reverse). Selected members of a compound shape always take the clicked mode. The setting is `window.pathfinder.compoundShapes`; no existing behavior changes while it is off. Scores and estimates unchanged. | -| 2026-11-01 | minor | Batch Trace Folder (`imageTrace.batchFolder`, #4921); evidence added; scores unchanged. | | 2026-11-06 | minor | Shape tools: a shape just drawn can be moved and turned with its tool still active — a press on the selection's centre point (Attributes › Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with its rotate cursor, as the Selection tool's do (#5477); the Selection tool takes the centre point too, so a press there moves an unfilled shape instead of starting a marquee; evidence: `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` (`crates/tools/src/shape/tests.rs`) and `a_press_on_the_centre_point_moves_an_unfilled_shape` (`select/tests.rs`); 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 (each field's scrub label was a second tab stop, invisible because only the label is drawn there, and the W/H link between W and H in the Control bar was one too, and now comes after the fields); evidence: `one_tab_moves_from_a_field_to_the_next` and `the_control_bar_link_toggles_by_click` in `crates/ui-egui/src/panels/tests_constrain.rs`; scores and estimates unchanged | -| 2026-11-01 | minor | Copy and paste ruler guides (#3226); evidence added; scores unchanged. | +| 2026-11-05 | minor | View & navigation: document tabs are reordered by dragging them along the tab strip (`document.move`, #5663); evidence added; scores unchanged. | +| 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): Object › Plug-ins › Compound Shapes lists Make, Release and Expand Compound Shape, and holds a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons make a compound shape on a click and give the permanent result on Alt-click (the default is the reverse). Selected members of a compound shape always take the clicked mode. The setting is `window.pathfinder.compoundShapes`; no existing behavior changes while it is off. Scores and estimates unchanged. | +| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | +| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | | 2026-11-04 | minor | Area type vertical alignment buttons in the Properties panel's Paragraph section and the Control panel, off for columns, rows and threaded frames (#3759); evidence added; scores unchanged. | | 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. | -| 2026-11-02 | minor | Wheel input over closed font-family fields changes selected text (#5253); public documentation and application choices distinguished; scores and estimates unchanged. | | 2026-11-03 | minor | Find Font's replacement-source chooser offers Document, Recent and System (#5217); scores and estimates unchanged. | | 2026-11-03 | minor | SVG imports preserve exported ellipses as four-anchor paths (#5243). Inline graphics use manual kerning (#5245). Symbol editing shows draggable 9-slice guides (#5280). Recolor Artwork offers smooth and segmented wheels and color bars (#5249). Scores and hours remain unchanged. | -| 2026-11-01 | minor | Repeat: a double-click isolates it to edit its source art, text included (#5054); evidence added; scores unchanged. | | 2026-11-03 | minor | Touch Type supports along-line RTL movement while retaining the first-logical-character restriction; scores and estimates unchanged. | -| 2026-11-01 | minor | Live Corners: Up/Down while dragging a corner widget step the corner kind (#2898); evidence added; scores unchanged. | | 2026-11-03 | minor | Rotated characters retain line-axis composition trimming; scores and estimates unchanged. | +| 2026-11-02 | minor | Wheel input over closed font-family fields changes selected text (#5253); public documentation and application choices distinguished; scores and estimates unchanged. | +| 2026-11-01 | minor | Batch Trace Folder (`imageTrace.batchFolder`, #4921); evidence added; scores unchanged. | +| 2026-11-01 | minor | Copy and paste ruler guides (#3226); evidence added; scores unchanged. | +| 2026-11-01 | minor | Repeat: a double-click isolates it to edit its source art, text included (#5054); evidence added; scores unchanged. | +| 2026-11-01 | minor | Live Corners: Up/Down while dragging a corner widget step the corner kind (#2898); evidence added; scores unchanged. | | 2026-11-01 | minor | Shortcuts for Effect menu items (#3296); evidence added; scores unchanged. | | 2026-11-01 | minor | Color Effects add-on (a VectorCraft addition): Find Nearest Swatch (#4923); evidence added; scores unchanged. | | 2026-11-01 | minor | Create Swatch Legend add-on under Object › Plug-ins › Color Effects (`addon.color.swatchLegend`, #4919); evidence added; scores unchanged. | @@ -2158,13 +2191,13 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-31 | minor | Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together; evidence added; scores unchanged. | | 2026-10-31 | minor | UI chrome: Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but unused; evidence added; score and hours unchanged (estimated). | | 2026-10-31 | minor | Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path; evidence added; scores unchanged. | -| 2026-10-30 | patch | Performance: opaque solid-fill knockout paths avoid redundant erase/add compositing in hard-edge frames, including supersampled raster export; analytical antialiasing keeps its existing rounding; scores and estimates unchanged | | 2026-10-31 | minor | Path Precision Tools add-on (a VectorCraft addition): live Path Effects; evidence added; scores unchanged. | | 2026-10-31 | minor | Artwork Cleanup add-on (a VectorCraft addition): text tools; evidence added; scores unchanged. | +| 2026-10-30 | minor | Effects: Color Halftone's Max Radius is 4–127 px of the Document Raster Effects resolution, shown in px whatever the units; older documents open converted, and files saved for older app versions hold points (#3260); scores unchanged. | +| 2026-10-30 | patch | Performance: opaque solid-fill knockout paths avoid redundant erase/add compositing in hard-edge frames, including supersampled raster export; analytical antialiasing keeps its existing rounding; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged | | 2026-10-30 | minor | 3D Extrude & Bevel, Inflate and Rotate apply to type, which stays editable (#4715); scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Vector Halftone; scores and estimates unchanged | -| 2026-11-10 | minor | Vector Halftone Preview leaves the dots unobscured and keeps the selection box (#6369). Scores and estimates unchanged. | | 2026-10-30 | minor | Path Precision Tools add-on: the Parametric Shape Tool; evidence added; scores unchanged. | | 2026-10-30 | minor | Presentation Mode fits the active artboard and hides selections, guides, grids and artboard edges; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: Select by Color Kind; scores and estimates unchanged | @@ -2192,20 +2225,24 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-29 | minor | Libraries panel: art dragged off the canvas and swatches dragged in (colours, also another library's, gradients and patterns) are added where they are dropped, as items dragged within the list move: on another item of their kind, just before or after it by the pointer's half and in its group; on a group, into it; away from the items, ungrouped at the end as before (`library.add` takes `group` and `before`/`after`). Tests: `tests::library::a_new_item_is_added_where_it_is_dropped`, `panels::libraries::tests::art_and_colours_dropped_from_elsewhere_go_where_they_are_dropped`; the library fuzz test adds with them. Scores and hours unchanged. | | 2026-10-29 | minor | Path operations: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; evidence added; score and hours unchanged (estimated). | | 2026-10-29 | minor | Object › Path › Smooth… with its slider and Auto-Smooth (#4115) added to the evidence; score and hours unchanged. | +| 2026-10-28 | minor | The center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn. Space freezes rotation on every sample, including the first; the initial samples establish size until the gesture begins, after which Space translates the flare without changing its size or turn. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged. | +| 2026-10-28 | minor | Drop Shadow Darkness subtracts the normalized amount from RGB source channels or adds it to CMYK/Gray black before profile conversion and blur; near-100% becomes gray black, with shadow opacity separate. SVG keeps live filters for uniform sRGB sources and renders shadows for other RGB profiles, source models and gradients. Gradient-stop adjustment is an application rule; spot/custom colors and composite-source parity remain open. Scores and estimates unchanged. | +| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | +| 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | | 2026-10-26 | minor | File formats: EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; evidence added ([file-format-parity.md](file-format-parity.md#eps-save-and-source-protection)); writing Illustrator's editing data (#868) and the `.ai` structure issues stay in the missing items; score and hours unchanged (estimated) | | 2026-10-26 | minor | View & navigation: New View… / Edit Views… taken off the missing items (all 25 saved views in the View menu, an Edit Views… list dialog, views checked on open); evidence added; score and hours unchanged. | | 2026-10-25 | minor | Type core: a double-click on a threaded port breaks the thread there (`text.thread.break`); evidence added; scores unchanged. | | 2026-10-25 | minor | Repeat: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); evidence added; score and hours unchanged (estimated). | -| 2026-10-24 | minor | Path operations: the Shape Builder's edge-source grid holds a bounded number of cell references (coincident segments no longer quadratic in memory) and files a segment once per cell; Path operations 80%, Shape Builder 70% unchanged (estimated).; area row and evidence list updated; the budget counts an item's distinct cells, so a degenerate grid's pieces don't send it to the scanned list. | -| 2026-10-24 | minor | Path operations: the Shape Builder's edge-source grid holds a bounded number of cell references (coincident segments no longer quadratic in memory) and files a segment once per cell; Path operations 80%, Shape Builder 70% unchanged (estimated). | | 2026-10-25 | minor | Floating panels open in OS windows of their own on the desktop (inside the window on the web and on Wayland); saved layouts from earlier versions convert to the shared docking layout (#4135); scores and estimates unchanged (estimated) | | 2026-10-25 | minor | Expand Appearance keeps a path's strokes as stroked paths (brushed strokes still become brush art) and Object › Expand… is dimmed while the selection has appearance attributes, so a stroked line with an effect expands to a stroked line before Expand outlines it (#3089, public doc cited). Scores and hours unchanged. | -| 2026-10-24 | minor | UI chrome: Preferences › General › Corner Radius is the Rounded Rectangle tool's radius and follows the radius set while drawing (#394); score unchanged at 80% (estimated), ~15 Preferences options still stored but unused. | | 2026-10-25 | minor | Transform Each opens on the values it was last confirmed with (Copy aside) and has a Copy button instead of a Copy checkbox (#1732, public doc cited for Copy; the remembered values are a maintainer's call on the issue). Scores and hours unchanged. | -| 2026-10-24 | minor | Recolor Artwork › Assign: double-clicking a row's new color opens the Color Picker over the dialog; its OK sets the row's new color in the model it was picked in (a CMYK mix stays CMYK, RGB becomes CMYK in a CMYK document), Cancel leaves the row as it was. A Lab new color is kept, but a Lab color can't be typed yet: the picker's Lab fields work in RGB, as for fill and stroke (part of #4028; Lab entry, swatch libraries and the row menu remain). Scores and estimates unchanged. | | 2026-10-25 | minor | Path operations: Pathfinder panel › Pathfinder Options… (Precision, Remove Redundant Points, Divide and Outline Will Remove Unpainted Artwork, #3426); evidence added, scores unchanged | | 2026-10-25 | minor | Artboards: the Artboards panel's Delete Empty Artboards (`artboard.deleteEmpty`); evidence added, scores unchanged | | 2026-10-25 | minor | UI: the Transform panel menu's Transform Object Only / Pattern Only / Both and Use Registration Point for Symbol act in the Transform panel and the Properties panel's Transform section; evidence added; scores unchanged. | +| 2026-10-24 | minor | Path operations: the Shape Builder's edge-source grid holds a bounded number of cell references (coincident segments no longer quadratic in memory) and files a segment once per cell; Path operations 80%, Shape Builder 70% unchanged (estimated).; area row and evidence list updated; the budget counts an item's distinct cells, so a degenerate grid's pieces don't send it to the scanned list. | +| 2026-10-24 | minor | Path operations: the Shape Builder's edge-source grid holds a bounded number of cell references (coincident segments no longer quadratic in memory) and files a segment once per cell; Path operations 80%, Shape Builder 70% unchanged (estimated). | +| 2026-10-24 | minor | UI chrome: Preferences › General › Corner Radius is the Rounded Rectangle tool's radius and follows the radius set while drawing (#394); score unchanged at 80% (estimated), ~15 Preferences options still stored but unused. | +| 2026-10-24 | minor | Recolor Artwork › Assign: double-clicking a row's new color opens the Color Picker over the dialog; its OK sets the row's new color in the model it was picked in (a CMYK mix stays CMYK, RGB becomes CMYK in a CMYK document), Cancel leaves the row as it was. A Lab new color is kept, but a Lab color can't be typed yet: the picker's Lab fields work in RGB, as for fill and stroke (part of #4028; Lab entry, swatch libraries and the row menu remain). Scores and estimates unchanged. | | 2026-10-24 | minor | Live Paint: Make, Merge and the Live Paint Bucket refuse type, images and other objects that aren't paths, with a message to convert them first, and change nothing; before, they vanished among the group's hidden sources (public doc cited). Scores and hours unchanged. | | 2026-10-24 | minor | Properties: the overview's contextual controls: Blend Options and Merge Live Paint in Quick Actions, the Width Profile list and an applied effect's Delete icon in Appearance, the kind and radius of Direct-Selected corners (public doc cited) (G1). Scores and hours unchanged. | | 2026-10-24 | minor | Commands: a command whose params take `ids?\|id?` (or `id?, ids?: … (instead of id)`) takes `id` as `ids` of one with nothing selected too, instead of refusing it as "nothing selected" (29 such commands needed a selection); Invert, Complement, Gradient Vector and Make Pixel Perfect act on the object given as `id`; an `id` or `ids` that is malformed or names no object is refused under the command's name instead of the selection standing in for it (`plugin.run`'s required `id`, the plug-in's, is not an object) (`tests_ids_without_selection::id_does_what_ids_of_one_does_with_nothing_selected`, `a_bad_id_is_refused_as_the_same_ids_are_and_changes_nothing`); scores unchanged | @@ -2229,19 +2266,17 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-23 | minor | UI chrome: General › Constrain Angle acts for the arrow keys, Shift drags and new rectangles, ellipses, grids, type, polygons, stars and size-dialog shapes (#394); score unchanged at 80% (estimated), hours unchanged | | 2026-10-23 | minor | SVG: attributes in other applications' XML namespaces are kept on their objects from import to export and in `.vectorcraft` files, as `data-*` attributes are (the rest of #522); SVG and engine regressions. Scores and hours unchanged. | | 2026-10-23 | minor | Type: hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room (G4); stale "Optical Margin Alignment" removed from the Type core row's remaining items. Scores and hours unchanged. | -| 2026-10-20 | minor | Type core: Hyphenation… and Justification… no longer listed as missing (done in #2310, #2755); evidence: a paragraph's Auto Leading drives the Character panel's Leading, the leading nudge and Fill with Placeholder Text; scores unchanged | -| 2026-10-22 | minor | Drawing tools: a Pen, Anchor Point or Direct Selection drag's preview no longer stalls (#834: the position where the pointer re-entered the window could be skipped, and the move in the release's frame was lost); score and hours unchanged (estimated) | | 2026-10-23 | minor | Guides and snapping: a path moved by a press on its stroke or beside its edge (Selection, Group Selection, Direct Selection), and a straight segment Direct Selection drags, snap by the nearest point of the path, so the path lands exactly on a ruler guide with "align" instead of the pressed spot landing there and the path a few pixels beside it (#3701); `tests_prefs::a_move_held_by_a_paths_stroke_lands_the_path_on_a_ruler_guide`, `tests_prefs::a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide`. Score and hours unchanged. | -| 2026-11-11 | minor | Guides and snapping: a Shift move slides the selection along its line onto a ruler guide by its edges and centre, as a move without Shift does (#4312); a guide a nearly parallel line only reaches a long way off pulls nothing. Score and hours unchanged (estimated). | -| 2026-10-22 | minor | Live vector effects: the Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects, its checkboxes visible (#885); the area row now lists only the options on a fill's or stroke's own Transform effect. Score and hours unchanged (estimated). | | 2026-10-23 | minor | COLR colour fonts (v0 and v1) draw and export as vector art; evidence added; scores unchanged. | -| 2026-10-22 | minor | Libraries panel: the buttons beside the library picker show the items as icons (the default) or in a list, colours and graphics as rows with their names (a colour's hex value too), dragged and dropped by row; graphic thumbnails are shrunk to the size they are drawn at, so thin strokes stay whole. Tests: `panels::libraries::tests::the_list_view_shows_names_and_takes_drops_by_row`, `the_panel_draws_its_libraries`. Scores and hours unchanged. | -| 2026-10-18 | minor | Shape Builder on art of thousands of segments (traced outlines, type outlines): lines cut it again past 4,096 segments, where the planar map used to give up and the tool offered whole shapes (#937's reproduced case; the planar map's edge-source and face lookups were quadratic in the segments, now indexed, and the cap is 65,536); Path operations stays 80% and Shape Builder 70% until confirmed on the reporter's file (estimated) | | 2026-10-23 | minor | Fix #3705 in part: drawing modes apply to pastes (Draw Inside puts Paste, Paste in Place and Paste on All Artboards inside the object drawn into; Draw Behind puts Paste in Place and Paste on All Artboards behind the selection), and Shift+D goes from Draw Behind back to Draw Normal when Draw Inside can't draw into the selection instead of stopping at Draw Behind; scores and estimates unchanged. | | 2026-10-23 | minor | Artboard Options: Show Video Safe Areas (action-safe 3.5% and title-safe 5% in from each edge, EBU R 95, canvas only) and Video Ruler Pixel Aspect Ratio (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio`), per artboard and in the native format; View › Rulers › Show Video Rulers (`view.videoRulers`) counts the active artboard in those pixels (#2849); evidence added; Layers/artboards 78% and Guides/rulers 80% unchanged (estimated). | | 2026-10-23 | minor | Artboard Options: Show Center Mark and Show Cross Hairs (`artboard.setProps`/`artboard.new` `showCenterMark`, `showCrossHairs`) draw a center cross and lines from the middle of each side through the center on the canvas only, in one undo step; video safe areas, video rulers and the pixel aspect ratio remain missing; scores and estimates unchanged. | | 2026-10-23 | minor | Path operations: Simplify of part of a path: with anchors or segments of a path direct-selected, Object › Path › Simplify (slider, Auto-Simplify and dialog) refits only each run of selected segments between its end anchors; no longer listed as missing; evidence added; score and hours unchanged (estimated). | | 2026-10-23 | minor | Path operations: Simplify: Object › Path › Simplify opens its slider at the Auto-Simplify level for the selection, previewing that result, instead of at 1 pt; partial-path simplify remains missing; score and hours unchanged (estimated). | +| 2026-10-23 | minor | Width-profile outlines sample adaptively: two samples per profile segment, refined where the width bends, and only where the flattened path does not already sample the width. The sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt by default) instead of one cubic per flatten step. On the reviewer's 1,500-ellipse fixture (20–200 pt ellipses, 3–12 pt weights) the PDF falls to 3.59 MB and the SVG to 6.79 MB (reported main baselines 4.48 MB and 9.30 MB, about 0.80× and 0.73×), and `profiled_outlines_stay_compact` guards the path-element count. Scores and estimates unchanged. | +| 2026-10-22 | minor | Drawing tools: a Pen, Anchor Point or Direct Selection drag's preview no longer stalls (#834: the position where the pointer re-entered the window could be skipped, and the move in the release's frame was lost); score and hours unchanged (estimated) | +| 2026-10-22 | minor | Live vector effects: the Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects, its checkboxes visible (#885); the area row now lists only the options on a fill's or stroke's own Transform effect. Score and hours unchanged (estimated). | +| 2026-10-22 | minor | Libraries panel: the buttons beside the library picker show the items as icons (the default) or in a list, colours and graphics as rows with their names (a colour's hex value too), dragged and dropped by row; graphic thumbnails are shrunk to the size they are drawn at, so thin strokes stay whole. Tests: `panels::libraries::tests::the_list_view_shows_names_and_takes_drops_by_row`, `the_panel_draws_its_libraries`. Scores and hours unchanged. | | 2026-10-22 | minor | Colour and gradients: Illustrator 30.0's gradient Method, Classic or Perceptual (Gradient panel menu, canvas and raster exports, SVG/PDF as images at the raster effects resolution, native file); score and hours unchanged (estimated). | | 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | | 2026-10-22 | minor | Dimension tool: in a CMYK document a dimension's lines, arrowheads and label are drawn in CMYK, black as K only (`0 0 0 1`) and other colours through the active colour settings, instead of an RGB black that printed as a four-ink rich black (#3233); RGB documents unchanged; `dimensions::dimension_colours_follow_the_document_colour_model`. Scores and hours unchanged. | @@ -2251,15 +2286,15 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); area score unchanged (49%, estimated). | | 2026-10-22 | minor | File formats: Affinity import gains master pages, tables, paragraph formats, text scale, shadows, glows, outlines (also on stroked shapes), overlays, underlines, strikethrough and editable embedded documents, strokes styled None left out, CMYK pixel layers through the document's CMYK profile, text placed as Affinity places it (AF.5, from storytold/designcraft#291); synthetic, corpus and local-thumbnail tests; score and hours unchanged (estimated). Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | -| 2026-10-21 | minor | Width-profile outlines offset samples added inside a flattened chord along the source curve's exact tangent, so a zoomed outline no longer jogs at flatten vertices; a regression test covers added samples. Scores and estimates unchanged. | | 2026-10-22 | minor | Font pickers list recently applied families first (#3515), using the existing recent-font limit; search, filters, previews and keyboard selection share the same rows. Scores and estimates unchanged. | +| 2026-10-22 | minor | Ruby (ふりがな) for live text: `type.ruby` sets/removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores and hours unchanged | +| 2026-10-21 | minor | Width-profile outlines offset samples added inside a flattened chord along the source curve's exact tangent, so a zoomed outline no longer jogs at flatten vertices; a regression test covers added samples. Scores and estimates unchanged. | | 2026-10-21 | minor | Plug-ins: filters keep data with the document (`documentData`), as its foreign data, so the native file needs no section of its own (G17); engine and unit regressions. Scores and hours unchanged. | | 2026-10-21 | minor | Print: PDF/X-1a and PDF/X-3 flattening in a CMYK document rasterizes as ink amounts, so CMYK colours under an image with see-through pixels keep their numbers, K included, instead of going through RGB (#2160); own-fixture test; scores and hours unchanged (estimated) | | 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation. Scores and estimates unchanged. | | 2026-10-21 | minor | UI chrome: workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset <name> returns to the saved layout (UI.7); score and hours unchanged (estimated) | | 2026-10-21 | minor | UI chrome: a panel group dragged onto a dock group's tabs joins it, onto its upper or lower half makes a new group above or below it, onto the icon column becomes icons (the tabbed group's panels too); dock tabs close from their context menu (UI.6); score and hours unchanged (estimated) | | 2026-10-21 | minor | UI chrome: a double-click on a dock tab collapses its group to its tabs and expands it again, as floating groups already did (UI.8); scores and hours unchanged (estimated) | -| 2026-10-20 | minor | UI chrome: workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset <name> returns to the saved layout (UI.7); score and hours unchanged (estimated) | | 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | | 2026-10-21 | minor | 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. Scores and estimates unchanged. | | 2026-10-21 | minor | Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. | @@ -2267,12 +2302,12 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-21 | minor | SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts. Scores and estimates unchanged. | | 2026-10-21 | minor | Snap to Glyph (#3124) removed from View navigation missing lists and menu stubs; scores and hours unchanged at this rounding | | 2026-10-21 | minor | Type core: System Layout (Character panel menu, whole document, off by default) is done; scores and hours unchanged | -| 2026-10-22 | minor | Ruby (ふりがな) for live text: `type.ruby` sets/removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores and hours unchanged | -| 2026-10-23 | minor | Width-profile outlines sample adaptively: two samples per profile segment, refined where the width bends, and only where the flattened path does not already sample the width. The sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt by default) instead of one cubic per flatten step. On the reviewer's 1,500-ellipse fixture (20–200 pt ellipses, 3–12 pt weights) the PDF falls to 3.59 MB and the SVG to 6.79 MB (reported main baselines 4.48 MB and 9.30 MB, about 0.80× and 0.73×), and `profiled_outlines_stay_compact` guards the path-element count. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | Properties: one selected clip group has Edit Clipping Path and Edit Contents at the top of the panel (public doc cited) (G1). Scores and hours unchanged. | | 2026-10-21 | minor | Print: the Print dialog's Media list carries the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5, which keep their ISO 216 sizes, plus はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; the JSON id or the dialog label names a paper, any case and spacing. Print score and hours unchanged. | | 2026-10-21 | minor | Type core: Fractional Widths (Character panel menu, per type object, on by default) is done; scores and hours unchanged | +| 2026-10-20 | minor | Type core: Hyphenation… and Justification… no longer listed as missing (done in #2310, #2755); evidence: a paragraph's Auto Leading drives the Character panel's Leading, the leading nudge and Fill with Placeholder Text; scores unchanged | +| 2026-10-20 | minor | UI chrome: workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset <name> returns to the saved layout (UI.7); score and hours unchanged (estimated) | | 2026-10-20 | minor | Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; existing opening gestures retained; scores and hours unchanged. | | 2026-10-20 | minor | Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; scores and hours unchanged. | | 2026-10-20 | minor | M3.57: CSS swatch libraries preserve fractional stop positions in linear and radial gradients; synthetic color-crate regression. Scores and hours unchanged. | @@ -2287,17 +2322,18 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-19 | minor | Status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates. Contributor original-file acceptance confirmed; scores and estimates unchanged. | | 2026-10-19 | minor | Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Regression coverage; scores and estimates unchanged. | | 2026-10-19 | minor | Cached canvas reprojection preserves scale during resize, rotation and DPI changes. Contributor original-file acceptance confirmed; scores and estimates unchanged. | -| 2026-10-18 | minor | PDF Place and clipboard paste use Open's live-text choice instead of forcing outlines; synthetic regressions cover page selection and explicit outlines. Scores and hours unchanged. | -| 2026-10-18 | minor | File formats: PDF invisible text (rendering mode 3, a scanned page's recognized text) opens as type with no fill or stroke instead of being dropped without a warning, and characters with no fill or stroke export as invisible text, so a scanned PDF opened and saved keeps its searchable text (#2675); own-fixture tests; scores and hours unchanged (estimated) | | 2026-10-19 | minor | Effect rasters cropped to the view or made coarser are kept while the view stays the same: a drag over a 500 pt blur draws it from the still frame on this thread and on the worker, and an interaction stays on the worker that drew the frame shown (#2558). Scores and hours unchanged. | | 2026-10-19 | minor | Color Picker layout (#2008): compact controls, centered header, stacked actions and aligned inputs; screen-reader channel values, current shared button names and styling, screen sampler and model-by-model Tab order retained. Narrow windows scroll vertically; scores and hours unchanged. | +| 2026-10-19 | minor | Blends: ease in/out and separate colour easing in Blend Options (#1124); on-canvas handles and easing in the Blend panel remain. Symbols, blends, envelopes score unchanged (estimated) | +| 2026-10-19 | minor | Guides, grids, smart guides, snapping: tangent and perpendicular snapping for lines drawn from an anchor (Pen, Curvature, Line Segment) and View › Snapping with every snap option, #2530; both only in part, so score and hours unchanged (estimated) | +| 2026-10-18 | minor | Shape Builder on art of thousands of segments (traced outlines, type outlines): lines cut it again past 4,096 segments, where the planar map used to give up and the tool offered whole shapes (#937's reproduced case; the planar map's edge-source and face lookups were quadratic in the segments, now indexed, and the cap is 65,536); Path operations stays 80% and Shape Builder 70% until confirmed on the reporter's file (estimated) | +| 2026-10-18 | minor | PDF Place and clipboard paste use Open's live-text choice instead of forcing outlines; synthetic regressions cover page selection and explicit outlines. Scores and hours unchanged. | +| 2026-10-18 | minor | File formats: PDF invisible text (rendering mode 3, a scanned page's recognized text) opens as type with no fill or stroke instead of being dropped without a warning, and characters with no fill or stroke export as invisible text, so a scanned PDF opened and saved keeps its searchable text (#2675); own-fixture tests; scores and hours unchanged (estimated) | | 2026-10-18 | minor | Shape Builder: Gap Detection (Small 3 pt, Medium 6 pt, Large 12 pt, Custom) in the new Shape Builder Tool Options bridges small gaps between open paths for the regions they bound (#2523); Path operations stays 80% (estimated) while #937 is open | | 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. | | 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them: the Top handle scales the repeat, while the center still moves it and the ends still rotate it. Headless canvas regression confirms vertical scaling without width change; scores and hours unchanged. | -| 2026-10-19 | minor | Blends: ease in/out and separate colour easing in Blend Options (#1124); on-canvas handles and easing in the Blend panel remain. Symbols, blends, envelopes score unchanged (estimated) | -| 2026-10-19 | minor | Guides, grids, smart guides, snapping: tangent and perpendicular snapping for lines drawn from an anchor (Pen, Curvature, Line Segment) and View › Snapping with every snap option, #2530; both only in part, so score and hours unchanged (estimated) | -| 2026-10-17 | minor | File formats: PDF text opens where the file draws it (#722: glyphs slid where the file's spacing differed from the font's metrics: kerning, word and character spacing, `TJ` adjustments, text rise, narrow vertical glyphs; justified text boxes stayed lines); score 75% and hours unchanged (estimated) | | 2026-10-18 | minor | Status-bar pointer X/Y follows the active global/artboard ruler origin and General units; three painted-text regressions cover origins, mode/artboard/reset/unit changes and read-only state; scores and hours unchanged. | +| 2026-10-17 | minor | File formats: PDF text opens where the file draws it (#722: glyphs slid where the file's spacing differed from the font's metrics: kerning, word and character spacing, `TJ` adjustments, text rise, narrow vertical glyphs; justified text boxes stayed lines); score 75% and hours unchanged (estimated) | | 2026-10-17 | minor | Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged. Windows native canvas checks and screenshots reviewed in PR #2643. | | 2026-10-17 | minor | Evidence: Artboard Options has X and Y beside a reference point locator, counted from the ruler origin (#1876); scores unchanged | | 2026-10-17 | minor | Image Trace: Auto Color keeps rare but distinct colours drawn as regions of their own (stems, highlights, near-white on pale backgrounds), at most six, protected from mixed-colour merging and Noise: every key colour on 66 of 66 flat test logos (was 39); scans unchanged. Scores and hours unchanged. | @@ -2308,6 +2344,8 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-17 | minor | M12: code-drawn centre-line scenes and metrics added to the ignored quality harness; current Create Strokes baseline in `image-trace-quality.md`. Tests only; scores, missing features and estimates unchanged. | | 2026-10-17 | minor | Plug-ins: Install Plug-in keeps the module in a Plug-ins folder next to the preferences, installed again at startup (#2541); scores unchanged | | 2026-10-17 | minor | Evidence: Group Selection picks and moves one path of a compound path (#2441, in part); scores unchanged | +| 2026-10-17 | minor | Evidence: Group Selection picks one path of a compound path (#2441, in part); scores unchanged | +| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. | | 2026-10-16 | minor | Create Gradient Mesh on an embedded image (#1676): colours sampled as area averages, transform kept, linked images refused. Scores and hours unchanged | | 2026-10-16 | minor | The Move dialog opens on the offset of the last drag or Move and with the Copy, Transform Objects and Preview it was last confirmed with (Transform Patterns sets the preference it starts from), so Enter, Enter repeats a move or a copy; scores and hours unchanged | | 2026-10-16 | minor | Image Trace: a loop or junction-returning piece whose fit collapsed to one cubic keeps the fewest segments that hold its area and course instead of every positioned point, so a higher Paths no longer gives fewer segments on code-drawn noisy images (six falling runs to none; one fall with another cause remains). Scores and hours unchanged. | @@ -2316,7 +2354,6 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-16 | minor | Image Trace Flat Logo: line art whose only solid area is the paper (thin strokes, small marks) is traced from its strokes' cores instead of returning a blank success with no paths; anti-aliased edge shades fold into their ink, and paper with ink on under 1% of it (specks, grain) stays blank; code-drawn regressions `thin_line_art_on_paper_is_traced_not_blank`, `anti_aliased_coloured_lettering_has_only_its_inks`, `sparse_marks_on_paper_are_not_promoted_to_colours`, `grainy_blank_paper_has_no_ink`. Score and hours unchanged (estimated) | | 2026-10-16 | minor | Image Trace: the contour walk works out each turn from the pixels as it goes instead of following a precomputed table of per-corner directions; identical loops on all 65,536 4 × 4 masks and 400 seeded masks, and no slower (whole 1 MP traces 1–5% faster). Scores and hours unchanged. | | 2026-10-16 | minor | Native documents restore transparency grid visibility (#1968); save/open regression covers on/off, old files and SVG; scores and hours unchanged (estimated) | -| 2026-10-17 | minor | Evidence: Group Selection picks one path of a compound path (#2441, in part); scores unchanged | | 2026-10-16 | minor | Agent automation: MCP cancellation, progress notifications and busy answers during long exports (#2165, #2171, #2172); scores and hours unchanged | | 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Type: localized legacy family names remain available when typographic names are English-only (#2208); loaded and catalog paths tested. Scores and estimates unchanged. | @@ -2326,10 +2363,10 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-16 | minor | G2: Effect › Sketch › Bas Relief, Charcoal, Photocopy and Stamp (#2527); measured Photoshop filters 37/57 with Cutout, Neon Glow and Poster Edges, 20 remaining (18–30 h). Scores unchanged. | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | | 2026-10-16 | minor | Symbols: Symbol Options › Export Type (Graphic / Movie Clip) with Registration; Symbols score 85% → 87% (estimated) | -| 2026-10-15 | minor | Evidence: 36 more tool states get cursors of their own (node editing, Anchor Point, Curvature, side-shear drawn; the rest borrowed until drawn), listed in `crates/tools/src/cursors.toml`; scores unchanged | -| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. | | 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged | | 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged | +| 2026-10-15 | minor | Evidence: 36 more tool states get cursors of their own (node editing, Anchor Point, Curvature, side-shear drawn; the rest borrowed until drawn), listed in `crates/tools/src/cursors.toml`; scores unchanged | +| 2026-10-15 | minor | Artboard tool: closing a clean active tab restores the displayed board before Properties Delete. Nine targeted checks passed; three original close-route captures reviewed; scores and hours unchanged. | | 2026-10-14 | minor | Localization: Brazilian Portuguese (`pt-br`) catalog completed under the tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85; full readiness unchanged (~62%) | | 2026-10-14 | minor | Automation: dataset CSV import/export; headline Automation ~62% → ~65%, detail ~68% → ~72%, hours 6–12 → 4–8 / 5–9 → 4–7 (estimated) | | 2026-10-14 | minor | Automation: image variables (`kind: image`, `variable.makeImageDynamic`, path values on datasets that keep object bounds); headline Automation ~60% → ~62%, detail ~65% → ~68%, hours 8–14 → 6–12 / 6–10 → 5–9 (estimated) | @@ -2346,15 +2383,14 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-14 | minor | Image Trace: closed outlines keep their interior when straightening would collapse them; generated mark/hole/winding/Noise regressions; score and hours unchanged | | 2026-10-14 | minor | Image Trace, measured in `image-trace-quality.md`: Shapes rework (#1845), square stroke caps (#1846), enhanced presets (#1847), gradients grown over noise and soft rims (#1848). Score and hours unchanged (estimated) | | 2026-10-14 | minor | Existing-guide Escape cancellation: eight app-input regressions, the Direct Selection Escape control and current full CI passed; scores and hours unchanged. | -| 2026-10-15 | minor | Artboard tool: closing a clean active tab restores the displayed board before Properties Delete. Nine targeted checks passed; three original close-route captures reviewed; scores and hours unchanged. | | 2026-10-14 | minor | Artboard tab changes align the tool with the displayed board; same-tab multiselection and Move/Scale Artwork options preserved. Three tab regressions and historical full CI on `a0671777` passed; scores and hours unchanged. | | 2026-10-14 | minor | Width-point finite-arithmetic follow-up: atomicity, history and native round-trip regressions and current full CI passed; scores and hours unchanged. | -| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | -| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | | 2026-10-14 | minor | Tools, UI and effects: Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing); Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674); In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666); Escape cancels a Direct Selection drag (#1803, #1743); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); A filled Pencil path keeps a stroke of None (#1804, part of #1679); With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663); The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661); In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635); Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756); Select › Same matches type on its characters' fill and stroke (#1746, #1737); Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787); Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757); Slider rails show on Medium Dark (#1808, #1662); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation); Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648); correction to the 2026-10-13 rows: a guide's position in the Transform panel (#1659) is not established by the documentation found (help pages read again on 2026-10-14) and is kept as a convenience pending the owner's decision, with no parity claim; scores unchanged (estimated) | | 2026-10-14 | minor | Files, untrusted input and automation: EPS import: a stitched shading keeps its hard colour changes (#1654, #1653); WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774); Linked files that change during a drag are updated after the drag instead of being missed (#1773); Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780); One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779); A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775); Tests: text import fuzzing no longer searches installed fonts (#1785); Tests: render tests that add fonts no longer race the frame cache tests (#1795); `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748); MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727); MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728); The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739); #1684 and #1797 wrote their own evidence; scores unchanged | | 2026-10-14 | minor | Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px); the Image Trace row keeps 75% (estimated): the score was raised for the measured work on 2026-10-13 and these two are refinements of it | | 2026-10-14 | minor | Malformed JSON app replies drop the MCP connection without replay and report uncertain completion (#1761); upstream notification and id-less refusal behavior preserved; scores and estimates unchanged. | +| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | +| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | | 2026-10-13 | minor | Files: SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620); A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604); A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613); scores unchanged (estimated) | | 2026-10-13 | minor | Tools, UI, effects and type: Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570); Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590); The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560); Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616); Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't); The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score; Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571); Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575); Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558); Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578); `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592); Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615); Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607); The Polish translator confirmed the Polish rows the maintainers had added after the catalog was written (#1614); scores unchanged (estimated) | | 2026-10-13 | minor | PDF.R1: editable PDF import uses the shared pinned reader; layer and overprint scans preserve state across content windows and reject known incomplete input. Synthetic streaming, decoder-limit and dependency-source regressions added; scores and hours unchanged. | @@ -2373,15 +2409,15 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-13 | minor | Tools and UI: Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); a round corner next to a curved side curves smoothly instead of sticking out of the shape and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512); Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Correction: the combined Average and Join with its corner or smooth choice was listed as done in [ui-parity.md](ui-parity.md) and does not exist in the app (`path.join` and `path.average` are separate commands, and Join has no such choice); it is now listed as missing there and in [gap 1](gaps.md); scores and hours unchanged (estimated) | | 2026-10-13 | minor | UI and tools: sliders in Save for Web, the raster export options, Preferences and the opacity popup show their rail, filled up to the handle (#1506, fixes #1483); in Outline view a template layer stays in Preview, dimmed (#1505, fixes #1486); Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1509, fixes #1508); a tab stop in the Tabs panel drags from where the press landed (#1487, fixes #1458); type: commands and MCP `add_text` report area type that does not fit its frame (#1500, fixes #1471); graphs: `graph.setData`'s CSV keeps each value exactly (#1485); scores and hours unchanged (estimated) | | 2026-10-13 | minor | 3D and Materials: outlined words and other shapes of many small curves take Rotate, Extrude & Bevel and Inflate (#1481), Twist runs 0° to 360° (#1524) and twisted walls keep their faces (#1525), all described in [three-d.md](three-d.md); rendering: print-size images draw, export and crop (#1517); `vectorcraft-cli perf` on Windows reports load and memory (#1502; no budget re-run); localization: French catalog complete again with 423 strings added and 188 reworded (#1511), a Hungarian message in the informal address (#1474); scores and hours unchanged (estimated) | -| 2026-10-12 | minor | File interop: Save As `.ai` also writes the legacy version 7 form (#868), keeping layers, groups, compound paths, clipping groups and type as text objects, one artboard per file, with Transparent Images, Hidden Objects and Text options; `.ai` legacy type is read on open; not yet tested by users who hand files back, so scores and hours are unchanged (estimated) | | 2026-10-13 | minor | Image Trace, measured in `image-trace-quality.md`: the palette is made of the picture's own colours (#1504): 16 Colors on flat art 70 → 4.2 paths and 5.1 → 0.13 px off, paths in a colour the art lacks 5.9 → 0; photos are smoothed within their tones first and stacked layers go by lightness (#1516): High Fidelity Photo 3,653 → 121 paths with its colour error 2.30 → 1.75, 16 megapixels traced where 4 were refused. Score and hours unchanged (estimated) | | 2026-10-13 | minor | 3D and Materials: Inflate takes Twist, Taper and Cap ([three-d.md](three-d.md)); scores unchanged (3D ~25%) | -| 2026-10-12 | minor | Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged | | 2026-10-13 | minor | Automation: a command run through MCP, the control channel or the CLI refuses a parameter key it doesn't take, naming it and the nearest accepted key (#1496, fixes #1442; 598 of 737 engine commands checked, 168 params descriptions corrected); plug-ins: a numeric parameter's default stays inside its declared range (#1463); stability: a window surface larger than 8192 px is configured up to the graphics adapter's limit instead of closing the app (#1495, part of #1459; not reproduced on a mixed-scale setup); text frames, wrap shapes and type paths beyond a billion points hold no text instead of hanging the layout (#1451); scores and hours unchanged (estimated) | | 2026-10-13 | minor | File formats: PDF and `.ai` import keep a stroke opaque and unmasked when the fill under it was faded or masked by its group, and a `.ai` read as its PDF part keeps its RGB numbers (#1434); SVG export writes knockout groups of opaque art without masks, and `convert` takes `--decimals` (#1435); grayscale TIFFs with an alpha channel or other extra channels place, relink and open (#1489, part of #1466; colourising 1-bit and grayscale images is still open); score and hours unchanged (estimated) | | 2026-10-13 | minor | Path operations and transforms: Split Into Grid has its row and column sizes, gutters, totals, Add Guides and Preview (#1488, fixes #1462); the Pathfinder panel works on a group selected alone (#1493, for #1450; one plain path alone is not settled by the documentation); Transform Objects in the Transform dialogs and Transform Each, and Reflect across an angle (#1494, fixes #1448 and #1460); Fit Headline in a frame one line tall (#1445, fixes #1444); value fields: selected text is readable (#1490, part of #1465) and a focused field follows its value (#1497, fixes #1457); the many-object edits of #1430 and #1464 are under Performance already; scores and hours unchanged (estimated) | -| 2026-10-12 | minor | Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged | | 2026-10-13 | minor | Image Trace: Black and White, Grayscale and Color place edges by the blend of the pixels across them and fit outlines as Flat Logo does; a harness measures every preset (`image-trace-quality.md`): Default's outlines 0.31 → 0.09 px off on average, Black and White Logo's 0.27 → 0.06 with 15 → 0.3 kinks, gaps between abutting paths 64 → 22 px. Score and hours unchanged (estimated) | +| 2026-10-12 | minor | File interop: Save As `.ai` also writes the legacy version 7 form (#868), keeping layers, groups, compound paths, clipping groups and type as text objects, one artboard per file, with Transparent Images, Hidden Objects and Text options; `.ai` legacy type is read on open; not yet tested by users who hand files back, so scores and hours are unchanged (estimated) | +| 2026-10-12 | minor | Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged | +| 2026-10-12 | minor | Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged | | 2026-10-12 | minor | TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged | | 2026-10-12 | minor | 3D and Materials: Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md)); 3D ~8% → ~25%, 50–85 → 30–55 h (estimated); menu 353/375 wired (measured from the stubs removed) | | 2026-10-12 | minor | File formats: an Affinity text frame too short for its first line is made taller with a warning instead of showing nothing (#1423); synthetic-file test; score and hours unchanged (estimated) | @@ -2405,8 +2441,6 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-12 | minor | Localization: Vietnamese (`vi`) catalog added, complete under the tests; Localization 63% → 71% (measured), hours 60–100 → 55–90; full readiness unchanged (~62%) | | 2026-10-12 | minor | Localization: Korean (`ko`) catalog added, complete under the tests; Localization 54% → 63% (measured), hours 70–110 → 60–100; full readiness unchanged (~62%) | | 2026-10-12 | minor | Desktop PDF spooler shared through craft-print; exact physical sizes, composed copies and Windows 7 fallback retained. Scores and remaining gaps unchanged. | -| 2026-10-11 | minor | Image Trace: an image over 64 megapixels (up to 192) is traced scaled down to 64 megapixels with its paths at the image's own size, instead of being refused; score and hours unchanged (estimated) | -| 2026-10-11 | minor | Czech render-site follow-up: file-dialog filters, diagnostics, preset numbering, contributor tooltips/plurals, gradient grammar and contextual printer mark styles; estimates unchanged | | 2026-10-12 | minor | Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged | | 2026-10-12 | minor | Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged | | 2026-10-12 | minor | Path operations: Simplify preview/counts, valid component selection after refit/subdivision (#1272); slider/auto/advanced UI remains open; scores/hours unchanged | @@ -2420,17 +2454,19 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-12 | minor | Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged | | 2026-10-12 | minor | Selection: segment picks are distinct from anchor picks; Delete preserves neighboring geometry (#1278), egui/native checks; scores unchanged | | 2026-10-12 | minor | Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged | +| 2026-10-12 | minor | UI chrome: native macOS save sheets gain a Format menu (#866), with extension regression tests and actual save/cancel verification; score and hours unchanged (estimated) | +| 2026-10-12 | minor | Compound-path appearance and fill-rule edits through directly selected members (#1197) verified with rendered holes and Undo/Redo; scores and hours unchanged | +| 2026-10-12 | minor | Path operations: Divide accepts open cutting paths (#1307); filled-face appearance, holes and bounded fallback regression-tested; scores and hours unchanged | +| 2026-10-12 | minor | UI chrome: numeric focus clicks select on release while drags retain their press position (#1326, in part); keyboard focus, stepping and double-click regressions pass; scores and hours unchanged; short movements below the click threshold remain open | +| 2026-10-12 | minor | DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves stay open and curves retained; rational/non-rational import and export versions regression-tested; scores/hours unchanged | +| 2026-10-11 | minor | Image Trace: an image over 64 megapixels (up to 192) is traced scaled down to 64 megapixels with its paths at the image's own size, instead of being refused; score and hours unchanged (estimated) | +| 2026-10-11 | minor | Czech render-site follow-up: file-dialog filters, diagnostics, preset numbering, contributor tooltips/plurals, gradient grammar and contextual printer mark styles; estimates unchanged | | 2026-10-11 | minor | The Graph Data worksheet (Import Data, Cell Style, Transpose, Switch x/y, Revert). Scores unchanged within rounding | | 2026-10-11 | minor | Graph label formatting kept through regeneration. Scores unchanged within rounding | | 2026-10-11 | minor | Graph Type's widths past 100%, First Row/Column in Front and Draw Filled Lines. Scores unchanged within rounding | | 2026-10-11 | minor | Graph Type's Add Drop Shadow and Add Legend Across Top. Scores unchanged within rounding | | 2026-10-11 | minor | Graph Data's Transpose and Switch x/y, `|` line breaks in graph labels, and the pie graph options (Legends in Wedges, Position, Sort). Scores unchanged within rounding | | 2026-10-11 | minor | Graph designs and marker designs (Object › Graph › Design… and Marker…): menu stubs 27 → 25; the graphs row's missing items are column designs and Image Trace depth. Scores unchanged within rounding | -| 2026-10-12 | minor | UI chrome: native macOS save sheets gain a Format menu (#866), with extension regression tests and actual save/cancel verification; score and hours unchanged (estimated) | -| 2026-10-12 | minor | Compound-path appearance and fill-rule edits through directly selected members (#1197) verified with rendered holes and Undo/Redo; scores and hours unchanged | -| 2026-10-12 | minor | Path operations: Divide accepts open cutting paths (#1307); filled-face appearance, holes and bounded fallback regression-tested; scores and hours unchanged | -| 2026-10-12 | minor | UI chrome: numeric focus clicks select on release while drags retain their press position (#1326, in part); keyboard focus, stepping and double-click regressions pass; scores and hours unchanged; short movements below the click threshold remain open | -| 2026-10-12 | minor | DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves stay open and curves retained; rational/non-rational import and export versions regression-tested; scores/hours unchanged | | 2026-10-11 | minor | Type core: Middle Eastern digit types (Character panel menu › Digits: Default, Arabic, Hindi, Farsi; `{digits}`) leave the missing list; score and hours unchanged at this rounding (estimated) | | 2026-10-11 | minor | PDF raster-effect cap reductions are reported per original object with requested/achieved ppi; scores and hours unchanged | | 2026-10-11 | minor | Platforms: Preferences › Performance › Linux Display Server opens the window through XWayland, where file drops work (#1151); score unchanged (estimated) | @@ -2446,21 +2482,17 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-10 | major | Full re-measure against Illustrator 2026 30.x from code counts, open issues and public docs; added breadth/depth/ready-for-real-work numbers, generative-AI row, calibration from PR timestamps; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file | | 2026-10-05 | major | (in ROADMAP.md) M3 and M4 rows re-derived after the paint and files milestones | | 2026-10-02 | major | (in ROADMAP.md) First 22-area weighted estimate | -| 2026-10-28 | minor | The center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn. Space freezes rotation on every sample, including the first; the initial samples establish size until the gesture begins, after which Space translates the flare without changing its size or turn. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged. | -| 2026-10-28 | minor | Drop Shadow Darkness subtracts the normalized amount from RGB source channels or adds it to CMYK/Gray black before profile conversion and blur; near-100% becomes gray black, with shadow opacity separate. SVG keeps live filters for uniform sRGB sources and renders shadows for other RGB profiles, source models and gradients. Gradient-stop adjustment is an application rule; spot/custom colors and composite-source parity remain open. Scores and estimates unchanged. | -| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | -| 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | > **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged. | Date | Change | Tab-drag history | |---|---|---| +| 2026-10-30 | minor | Gradient panel presets (30.0) done: the colour area no longer lists them; scores and estimates unchanged. | | 2026-10-22 | minor | Verify Selection-tool reset after tab activation, drop and cancellation; narrow clipboard evidence to the tested internal clipboard and correct documentation grammar; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | | 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged | -| 2026-10-30 | minor | Gradient panel presets (30.0) done: the colour area no longer lists them; scores and estimates unchanged. | 2026-10-28 Flare interaction: the center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn; Space during either drag moves the flare without changing its size or turn, including the first drag sample. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged. ## EPS Save evidence (2026-10-26) -Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. Since 2026-11-12 the EPS Options of Save As show Use Artboards, unchecked by default: unchecked, the bounding box fits the visible art; checked, Save keeps the chosen page frame and margins described here. \ No newline at end of file +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. Since 2026-11-12 the EPS Options of Save As show Use Artboards, unchecked by default: unchecked, the bounding box fits the visible art; checked, Save keeps the chosen page frame and margins described here. diff --git a/docs/type-parity.md b/docs/type-parity.md index 30547d5a17..c6a2d61a74 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,7 +32,8 @@ 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 | +| 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). | | 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 | @@ -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 dbe69b7603..81cfe1e0e1 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-12 · **Change:** minor (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). > @@ -51,12 +51,9 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | -| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | -| 2026-10-30 | minor | Home screen: welcome, New / Open and Community are centred and the large Discord button uses the theme accent, part of #4684; scores and estimates unchanged (estimated) | | 2026-11-06 | minor | Snap to Grid: a straight segment dragged with Direct Selection lands its anchors on the grid, not the pressed point (#3835); scores and estimates unchanged | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color follows the hex and a double-click opens the Color Picker over the dialog, whose OK fills Color (#5364); scores and estimates unchanged | | 2026-11-06 | minor | Drop Shadow, Outer Glow and Inner Glow: spinner arrows beside Opacity, the offsets, Blur and Darkness (#5367, #5368); scores and estimates unchanged | @@ -85,11 +82,14 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | File › Export › Export As PNG… exports the active artboard, or each artboard the Artboard tool has selected (one file each), instead of always the first (#5502); a choice the caller gives is kept; scores and estimates unchanged | | 2026-11-04 | minor | Document Tools add-on (a VectorCraft addition): Object › Plug-ins › Document Tools › Batch Trace Folder asks for a folder of images and a destination folder and traces each image into it (#4921); scores and estimates unchanged | +| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-03 | minor | The Action Options row cites the public Actions panel page and describes its controls. Scores and estimates remain unchanged. | | 2026-11-03 | minor | Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | +| 2026-10-30 | minor | Home screen: welcome, New / Open and Community are centred and the large Discord button uses the theme accent, part of #4684; scores and estimates unchanged (estimated) | | 2026-10-26 | minor | Pencil Fidelity goes down to 0.01 pt (dialog, tool and engine; Smooth keeps 0.05) on a logarithmic slider across the Tool Options row, with hundredths in the field and key steps that fit the range (#3328); scores and estimates unchanged (estimated) | | 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-2; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | @@ -284,7 +284,7 @@ tools, never checked side by side) isn't captured row by row. | Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370. The field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field, so a panel scrolled under a still pointer keeps scrolling past the field. This rule and its 0.5 s are VectorCraft's choices. | | Numeric spinner arrows step once on a click and keep repeating while held with the mouse | done | #6307; headless test `a_held_spinner_arrow_repeats` holds past egui's click timeout and checks release. | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | -| Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | +| Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; a release more than 1 pt from the press keeps the caret, a tap within 1 pt selects all (#6659) | | Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864). A file that records no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files) opens and reverts in General units; native, SVG and DXF files and a `.ai` or EPS with ruler units keep theirs, as a [Community Expert's answer](https://community.adobe.com/questions-652/change-units-from-points-to-inches-won-t-stick-765372) describes (#5820) | | Units › East Asian Type (Q and Ha) | done | #394: `unitsAsianType` offers every unit and Q and Ha, a quarter of a millimetre each ([Scripting Guide's Measurement Units](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/): 0.709 pt per Q; [InDesign's unit notation](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html): `q`, `h`); only it lists Q and Ha. The public pages do not say which fields use it, so no field uses it yet and that waits for a source. Tests `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` | | Field precision: 3 decimals for steps; values kept as f64 | done | | @@ -332,7 +332,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 @@ -365,6 +365,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Interaction | Status | Evidence | |---|---|---| +| Ordinary Save refused by filesystem permissions/read-only storage offers Save As | focused UI regressions passed; native acceptance pending | #6146; `io::tests_read_only_save`; same name/format suggested; unchanged format keeps options, changed format opens its Save options; Documents folder, UID-bound continuation; cancellation and automation isolation | | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | | A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | @@ -520,43 +521,48 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Numeric fields: a 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 estimates unchanged. | | 2026-11-12 | minor | Remove Background for a selected image in the Contextual Task Bar, Properties › Quick Actions and the Control bar, which runs at once with its defaults. 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 | 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). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | -| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | -| 2026-11-10 | minor | Symbol libraries: Inventory Icons joins Basic Shapes, UI Frames and Map Markers as a fourth built-in library (Sword, Shield, Potion, Key, Gem; no default 9-slice). | | 2026-11-11 | minor | Files that record no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files, importer plug-ins) open and revert in Preferences › Units › General instead of points; native, SVG and DXF files and `.ai`/EPS ruler units are kept (#5820). Scores and estimates unchanged. | +| 2026-11-11 | minor | Blob Brush: while painting, the preview of a round tip is the outline of the area the stroke adds, round at both ends, as the stroke it commits (#4343). Scores unchanged (estimated). | +| 2026-11-10 | minor | Symbol libraries: Inventory Icons joins Basic Shapes, UI Frames and Map Markers as a fourth built-in library (Sword, Shield, Potion, Key, Gem; no default 9-slice). | | 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | +| 2026-11-10 | minor | Vector Halftone Preview leaves the dots unobscured and keeps the selection box (#6369). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | -| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | | 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | +| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | | 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, as with Direct Selection chosen, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged | | 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected, a Shift-click on a corner widget picks that corner alone instead of deselecting it (#5188); adding a hidden corner's widget by Shift-click stays open (#1232); scores and estimates unchanged | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | | 2026-11-06 | minor | Keyboard shortcuts: Cmd/Ctrl+Alt+. and Cmd/Ctrl+Alt+, (Stroke Weight Up and Down) step each selected stroke's weight, or the next object drawn's (#5797); scores and estimates unchanged | | 2026-11-06 | minor | Properties: Quick Actions has Repeat Options for one selected radial, grid or mirror repeat, opening Object › Repeat › Options with its values (#5181); scores and estimates unchanged | | 2026-11-06 | minor | Control bar: with type selected, a Paragraph: link (the Paragraph panel in a popover) and the Align left, Align center and Align right buttons follow the Character controls; the bar leaves them out from its right end when short of room, after X/Y/W/H and the Align buttons (#5372); scores and estimates unchanged | +| 2026-11-05 | minor | Document tabs are reordered by dragging them along the tab strip (`document.move`, #5663). | +| 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): Object › Plug-ins › Compound Shapes lists Make, Release and Expand Compound Shape, and holds a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons make a compound shape on a click and give the permanent result on Alt-click (the default is the reverse). Selected members of a compound shape always take the clicked mode. The setting is `window.pathfinder.compoundShapes`; no existing behavior changes while it is off. Scores and estimates unchanged. | | 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | | 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | -| 2026-11-05 | minor | Document tabs are reordered by dragging them along the tab strip (`document.move`, #5663). | | 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… rounds only the corners in an angle range (#5545); scores and estimates unchanged | | 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… rounds all, the odd-numbered or the even-numbered anchors (#5546); scores and estimates unchanged | -| 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): Object › Plug-ins › Compound Shapes lists Make, Release and Expand Compound Shape, and holds a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons make a compound shape on a click and give the permanent result on Alt-click (the default is the reverse). Selected members of a compound shape always take the clicked mode. The setting is `window.pathfinder.compoundShapes`; no existing behavior changes while it is off. Scores and estimates unchanged. | | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen document, as the pane's options are listed "for the selected preset" ([public source](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-and-save-custom-document-presets.html)); the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units (#2078); scores and estimates unchanged | -| 2026-11-01 | minor | Object › Image Trace › Batch Trace Folder asks for a source and a destination folder and traces each image into it (#4921); scores and estimates unchanged | -| 2026-11-01 | minor | Edit › Copy and Cut take the selected ruler guides, with the objects or alone; pasting puts them on the layer new art goes to, moved as the objects are (alone: where they were), Paste in Place onto the active artboard and Paste on All Artboards onto each (#3226); scores and estimates unchanged | | 2026-11-04 | minor | Area type: Align Top / Center / Bottom / Justify Vertically buttons in the Properties panel (Paragraph section) and the Control panel set its vertical alignment in the frame, as Area Type Options does; turned off for frames with rows or columns and threaded frames (#3759); scores and estimates unchanged | +| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-03 | minor | Enclosing Lasso selects fully enclosed objects with a freehand loop under Object › Plug-ins; scores and estimates unchanged. | | 2026-11-03 | minor | Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show); scores and estimates unchanged | -| 2026-11-02 | minor | Font-family fields in Character, Properties and Control accept the wheel without opening the menu (#5253); scores and estimates unchanged. | | 2026-11-03 | minor | Symbol editing draws and drags 9-slice guides on the canvas (#5280). Guides outside the artwork remain unsupported. Scores and hours remain unchanged. | +| 2026-11-02 | minor | Font-family fields in Character, Properties and Control accept the wheel without opening the menu (#5253); scores and estimates unchanged. | +| 2026-11-01 | minor | Object › Image Trace › Batch Trace Folder asks for a source and a destination folder and traces each image into it (#4921); scores and estimates unchanged | +| 2026-11-01 | minor | Edit › Copy and Cut take the selected ruler guides, with the objects or alone; pasting puts them on the layer new art goes to, moved as the objects are (alone: where they were), Paste in Place onto the active artboard and Paste on All Artboards onto each (#3226); scores and estimates unchanged | | 2026-11-01 | minor | A double-click on a Repeat with the Selection tool enters isolation mode on it, where its source art (type included: a double-click there edits the text) is edited and every copy follows (#5054); scores and estimates unchanged | | 2026-11-01 | minor | Live Corners: while a corner widget is pressed or dragged with the Selection or Direct Selection tool, the Up and Down arrows step the corners' kind (round, inverted round, chamfer) with the radius, as one undo step (#2898); scores and estimates unchanged | | 2026-11-01 | minor | Keyboard Shortcuts: every Effect menu item (Distort & Transform › Transform…, Stylize › Drop Shadow…, …) is listed under its menu path and takes a shortcut of its own, which the menu shows and which opens that effect's dialog (or applies an effect without options) (#3296); scores and estimates unchanged | -| 2026-10-31 | minor | Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but unused; scores and estimates unchanged | | 2026-11-01 | minor | Edit › Copy and Cut take the selected ruler guides, with the objects or alone; pasting puts them on the layer new art goes to, moved as the objects are (alone: where they were), Paste in Place onto the active artboard and Paste on All Artboards onto each; a Copy of guides alone empties the system clipboard, so Paste takes the guides, not what another app copied before (#3226); scores and estimates unchanged | | 2026-11-01 | minor | Color Effects add-on (a VectorCraft addition): Find Nearest Swatch (#4923); scores and estimates unchanged | | 2026-11-01 | minor | Artboards panel: a row dragged above or below another reorders the artboards (#4687); scores and estimates unchanged | @@ -566,7 +572,6 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Randomize Opacity (#4925); scores and estimates unchanged | | 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Sort Objects (#4917); scores and estimates unchanged | | 2026-11-01 | minor | Blob Brush and Eraser: the tip's outline (size, angle, roundness, at the zoom) follows the pointer before and while painting, around a small centre dot, updating at once with `[` / `]` and Tool Options; precise cursors (Caps Lock) show only the crosshair (#4977); scores and estimates unchanged (estimated) | -| 2026-11-11 | minor | Blob Brush: while painting, the preview of a round tip is the outline of the area the stroke adds, round at both ends, as the stroke it commits (#4343). Scores unchanged (estimated). | | 2026-11-01 | minor | Layers panel: an open blend lists its spine as a `<Path>` row below its key objects, and a click selects the blend so Direct Selection edits the spine (#4820); scores and estimates unchanged | | 2026-11-01 | minor | Pen with Cmd/Ctrl held: the lent Selection tool no longer scales the path being drawn by bounding-box handles the canvas hides while its end anchor is selected (a flat path did nothing under a resize pointer); a Cmd drag moves the path instead, and with Direct Selection moves anchors and segments, one undo step each (#4980); scores and estimates unchanged | | 2026-11-01 | minor | Dynamic symbols (#4938): Symbol Options › Symbol Type; per-instance appearance for objects of a dynamic symbol's art; Reset in the Properties panel and the Symbols panel menu; on-canvas Direct Selection of the parts remains | @@ -578,6 +583,7 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Group Overlapping Objects (#4928); scores and estimates unchanged | | 2026-11-01 | minor | Character panel › Language: the selected characters' language, from Document Setup's list, which gains Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu (#4946, #4943); the Digits menu's numeral names keep their own translations; scores unchanged | | 2026-11-01 | minor | VectorCraft Options' Version is a menu of the versions VectorCraft writes, each named by the releases that read it, and an uncompressible version turns Use Compression off (#4931); a save refused before writing shows the Can't save alert with the reason (#4933); About › Contributors' table keeps its header row above the scrolling rows (#4944); the Gradient panel's freeform point swatch opens the point's popover (#4953); scores unchanged | +| 2026-10-31 | minor | Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but unused; scores and estimates unchanged | | 2026-10-31 | minor | A file opened from the file manager while VectorCraft runs opens as a new tab in that window, which comes to the front, on Windows and Linux (#4892); `--new-instance` opts out; scores unchanged | | 2026-10-31 | minor | Object › Path › Smooth: Auto-Smooth takes a shaky line to one smooth curve, and direct-selected anchors smooth only their section; scores unchanged | | 2026-10-31 | minor | New Document: More Settings has Save Preset beside the name, as Preset Details does, and saves its settings (#4893); scores and estimates unchanged | @@ -594,7 +600,6 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | 2026-10-31 | minor | Artwork Cleanup add-on (a VectorCraft addition): its text tools in the panel and Object › Plug-ins › Artwork Cleanup; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Vector Halftone; scores and estimates unchanged | -| 2026-11-10 | minor | Vector Halftone Preview leaves the dots unobscured and keeps the selection box (#6369). Scores and estimates unchanged. | | 2026-10-30 | minor | Path Precision Tools add-on: the Parametric Shape Tool (ten re-editable shape kinds), its panel and Convert to Parametric Shape; scores and estimates unchanged | | 2026-10-30 | minor | Smart Pen add-on: an Alt-drag away from paths selects points with a marquee; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: Select by Color Kind; scores and estimates unchanged | @@ -620,17 +625,19 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | 2026-10-30 | minor | Gradient panel: Presets menu and Save/Delete Gradient Preset (30.0); scores and estimates unchanged | | 2026-10-29 | minor | Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; scores and estimates unchanged | | 2026-10-29 | minor | Object › Path › Smooth… with its slider and Auto-Smooth (#4115); scores and estimates unchanged | +| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | +| 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | | 2026-10-25 | minor | Selection tool: a double-click on a threaded port breaks the thread there (`text.thread.break`); scores and estimates unchanged | | 2026-10-25 | minor | A selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); headless canvas and tool regressions; scores and estimates unchanged. | | 2026-10-25 | minor | Floating panels open in OS windows of their own on the desktop (inside the window on the web and on Wayland); saved layouts from earlier versions convert to the shared docking layout (#4135); scores and estimates unchanged (estimated) | | 2026-10-25 | minor | Object › Expand… is dimmed for a selection with appearance attributes, with a tooltip to use Object › Expand Appearance first, so a stroked line with an effect expands to a stroked line before its stroke is outlined (#3089). Scores and estimates unchanged. | -| 2026-10-24 | minor | G12: General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is active reaches it, and the radius ↑/↓ set while drawing (or a Tool Options value) becomes the preference, within 0–1296 pt (#394, `crates/engine/src/tests_constrain_tools.rs`); no entry closed, estimate unchanged. | -| 2026-10-24 | minor | G12: General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is active reaches it, and the radius ↑/↓ set while drawing (or a Tool Options value) becomes the preference, within 0–1296 pt (#394, `crates/engine/src/tests/constrain_tools.rs`); no entry closed, estimate unchanged. | | 2026-10-25 | minor | Transform Each opens on the values it was last confirmed with (Copy aside) and has a Copy button left of Cancel instead of a Copy checkbox (#1732). Scores and estimates unchanged. | -| 2026-10-24 | minor | Recolor Artwork › Assign: double-clicking a row's new color opens the Color Picker over the dialog; its OK sets the row's new color in the model it was picked in (a CMYK mix stays CMYK, RGB becomes CMYK in a CMYK document), Cancel leaves the row as it was. A Lab new color is kept, but a Lab color can't be typed yet: the picker's Lab fields work in RGB, as for fill and stroke (part of #4028; Lab entry, swatch libraries and the row menu remain). Scores and estimates unchanged. | | 2026-10-25 | minor | Pathfinder panel › Pathfinder Options… opens a dialog with Precision, Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork, which the panel's operations use (#3426); scores unchanged | | 2026-10-25 | minor | Artboards panel › Delete Empty Artboards deletes the artboards with no artwork in one undo step and makes one that is left active; scores unchanged | | 2026-10-25 | minor | Transform panel menu: Transform Object Only / Pattern Only / Both and Use Registration Point for Symbol act in the Transform panel and the Properties panel's Transform section; scores and estimates unchanged | +| 2026-10-24 | minor | G12: General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is active reaches it, and the radius ↑/↓ set while drawing (or a Tool Options value) becomes the preference, within 0–1296 pt (#394, `crates/engine/src/tests_constrain_tools.rs`); no entry closed, estimate unchanged. | +| 2026-10-24 | minor | G12: General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is active reaches it, and the radius ↑/↓ set while drawing (or a Tool Options value) becomes the preference, within 0–1296 pt (#394, `crates/engine/src/tests/constrain_tools.rs`); no entry closed, estimate unchanged. | +| 2026-10-24 | minor | Recolor Artwork › Assign: double-clicking a row's new color opens the Color Picker over the dialog; its OK sets the row's new color in the model it was picked in (a CMYK mix stays CMYK, RGB becomes CMYK in a CMYK document), Cancel leaves the row as it was. A Lab new color is kept, but a Lab color can't be typed yet: the picker's Lab fields work in RGB, as for fill and stroke (part of #4028; Lab entry, swatch libraries and the row menu remain). Scores and estimates unchanged. | | 2026-10-24 | minor | Properties: Merge Live Paint shows only when the selection holds nothing Live Paint refuses (type, images and other objects that aren't paths), which `livePaint.merge` and Make now refuse too. Scores unchanged. | | 2026-10-24 | minor | G1: Properties has the additional contextual controls of the public Properties panel overview: Blend Options and Merge Live Paint in Quick Actions, the Width Profile list and an applied effect's Delete icon in Appearance, and the kind and radius of corners selected with Direct Selection. Scores unchanged. | | 2026-10-24 | minor | Docs catch-up: Relative rounding (#3091) marked done; the stale "Video rulers: missing" duplicate row removed (done, #2849). Scores and estimates unchanged. | @@ -644,13 +651,13 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | 2026-10-23 | minor | Pencil: Shift held at the press draws a straight line at 45° steps from the Constrain Angle (part of #3799). Scores and estimates unchanged. | | 2026-10-23 | minor | Fix #3705: in Draw Inside, art drawn or pasted inside the isolated object takes isolation mode into its clip group, so it can be clicked and isn't dimmed (it used to stay locked and dimmed beside the isolated path); undo or redo that removes the isolated object ends isolation mode; scores and estimates unchanged. | | 2026-10-23 | minor | Preferences › General › Constrain Angle: arrow keys, Shift drags and new rectangles, ellipses, grids, type, polygons, stars and size-dialog shapes follow it (#394). Scores and estimates unchanged. | -| 2026-10-22 | minor | Pen, Anchor Point and Direct Selection drags: the preview gets every pointer position, including where it re-enters the window and the move in the release's frame (#834); score unchanged (estimated) | | 2026-10-23 | minor | Guides: a path moved by a press on its stroke or beside its edge, and a straight segment Direct Selection drags, snap by the nearest point of the path, landing exactly on a ruler guide (#3701); scores and estimates unchanged. | | 2026-10-23 | minor | Fix #3705 in part: drawing modes apply to pastes (Draw Inside puts Paste, Paste in Place and Paste on All Artboards inside the object drawn into; Draw Behind puts Paste in Place and Paste on All Artboards behind the selection), and Shift+D goes from Draw Behind back to Draw Normal when Draw Inside can't draw into the selection instead of stopping at Draw Behind; scores and estimates unchanged. | | 2026-10-23 | minor | Artboard Options: Show Video Safe Areas (action-safe 3.5% and title-safe 5% in from each edge, EBU R 95, canvas only) and Video Ruler Pixel Aspect Ratio, per artboard; View › Rulers › Show Video Rulers counts the active artboard in those pixels (#2849); two rows done; scores and estimates unchanged. | | 2026-10-23 | minor | Artboard Options: Show Center Mark and Show Cross Hairs (`artboard.setProps`/`artboard.new` `showCenterMark`, `showCrossHairs`) draw a center cross and lines from the middle of each side through the center on the canvas only, in one undo step; video safe areas, video rulers and the pixel aspect ratio remain missing; scores and estimates unchanged. | | 2026-10-23 | minor | Simplify: Object › Path › Simplify opens its slider at the Auto-Simplify level for the selection, previewing that result, instead of at 1 pt; partial-path simplify remains missing; scores and estimates unchanged. | | 2026-10-23 | minor | Simplify of part of a path: with anchors or segments of a path direct-selected, Object › Path › Simplify (slider, Auto-Simplify and dialog) refits only each run of selected segments between its end anchors; the Simplify row is done (its stale duplicate removed); scores and estimates unchanged. | +| 2026-10-22 | minor | Pen, Anchor Point and Direct Selection drags: the preview gets every pointer position, including where it re-enters the window and the move in the release's frame (#834); score unchanged (estimated) | | 2026-10-22 | minor | Gradient panel: the Method menu, Classic or Perceptual (Illustrator 30.0); scores and estimates unchanged. | | 2026-10-22 | minor | Gradient panel: the Dither box (Illustrator 30.0); scores and estimates unchanged. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | @@ -658,27 +665,27 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | 2026-10-21 | minor | Workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset <name> returns to the saved layout; score unchanged (estimated) | | 2026-10-21 | minor | Panels dragged by the mouse join a dock group as tabs, split it top/bottom or become icons in the icon column; dock tabs close from their context menu; score unchanged (estimated) | | 2026-10-21 | minor | A double-click on a dock tab collapses its group to its tabs, as floating groups already did, and expands it again; another of its tabs expands it (`window.panel.collapse`); scores unchanged (estimated) | -| 2026-10-20 | minor | Workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset <name> returns to the saved layout; score unchanged (estimated) | | 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | | 2026-10-21 | minor | 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. Scores and estimates unchanged. | | 2026-10-21 | minor | Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top (public doc cited in target-app-parity.md). Scores unchanged. | -| 2026-10-19 | minor | Distribute Objects and Distribute Spacing space the selected ruler guides across the axis with the objects, as items with no size, and two or more guides selected alone (#1597 in part); with a key guide and a spacing, the guide stays put and the other items are spaced from it (key guide from #3165); the Distribute Spacing value is enabled for a key guide; scores unchanged | | 2026-10-21 | minor | Snap to Glyph marked done (#3124); scores unchanged | +| 2026-10-20 | minor | Workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset <name> returns to the saved layout; score unchanged (estimated) | | 2026-10-20 | minor | The selection tools (Selection, Direct Selection, Group Selection) take a clipping path on its outline, within the selection tolerance, before the art it clips, so its segments can be dragged where the art covers the mask's edge; Select Behind lists the same, and the eyedropper and the tools that act on what is drawn are unchanged. Doc and tool regression tests. Scores unchanged. | | 2026-10-20 | minor | Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; existing opening gestures retained; scores and hours unchanged. | | 2026-10-20 | minor | Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; scores and hours unchanged. | +| 2026-10-19 | minor | Distribute Objects and Distribute Spacing space the selected ruler guides across the axis with the objects, as items with no size, and two or more guides selected alone (#1597 in part); with a key guide and a spacing, the guide stays put and the other items are spaced from it (key guide from #3165); the Distribute Spacing value is enabled for a key guide; scores unchanged | | 2026-10-19 | minor | A ruler guide selected with objects can be the key object (#2143): the Selection tool's click on it, or Align to Key Object with one object; drawn thicker; Align lines the objects up on it and the guide stays put; the other way round, selected guides align to a key object, which one object selected with guides can be; scores unchanged | | 2026-10-19 | minor | Cache status-readout sample widths per UI, language, document unit and pixels per point; regressions check reuse across live frames and refresh after display-setting changes. Scores and estimates unchanged. | | 2026-10-19 | minor | Status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates. Contributor original-file acceptance confirmed; scores and estimates unchanged. | | 2026-10-19 | minor | Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Regression coverage; scores and estimates unchanged. | | 2026-10-19 | minor | Cached canvas reprojection preserves scale during resize, rotation and DPI changes. Contributor original-file acceptance confirmed; scores and estimates unchanged. | | 2026-10-19 | minor | Color Picker layout (#2008): compact controls, centered header, stacked actions and aligned inputs; screen-reader channel values, current shared button names and styling, screen sampler and model-by-model Tab order retained. Narrow windows scroll vertically; scores and hours unchanged. | -| 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. | -| 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them: the Top handle scales the repeat, while the center still moves it and the ends still rotate it. A headless canvas regression confirms height changes while width stays fixed; scores and hours unchanged. | | 2026-10-19 | minor | Blend Options: Ease In / Ease Out and Color Ease In / Out sliders with live preview (#1124); the Blend panel row stays partial (no easing in the panel, no on-canvas handles) | | 2026-10-19 | minor | Smart guides and snapping: tangent and perpendicular snapping for lines drawn from an anchor and View › Snapping (#2530), both partial; scores and hours unchanged | +| 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. | +| 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them: the Top handle scales the repeat, while the center still moves it and the ends still rotate it. A headless canvas regression confirms height changes while width stays fixed; scores and hours unchanged. | | 2026-10-18 | minor | Status-bar pointer X/Y follows the active global/artboard ruler origin and General units; three painted-text regressions cover origins, mode/artboard/reset/unit changes and read-only state; scores and hours unchanged. | | 2026-10-17 | minor | Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged. Windows native canvas checks and screenshots reviewed in PR #2643. | | 2026-10-17 | minor | Artboard Options has X and Y beside a reference point locator, counted from the ruler origin as the Transform panel's are; a typed X or Y moves the artboard's reference point there and a new Width or Height keeps it in place (#1876); scores unchanged (estimated) | @@ -693,29 +700,29 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | Anchor Point tool: a click on one handle end retracts that handle only; the anchor becomes a corner and keeps the other handle (#2267). Handle drags start past the same 2 px as anchor drags. Scores and hours unchanged. | | 2026-10-16 | minor | Smart Guides: the dragged end of a straight segment snaps to the segment's extension at any angle, with the guide from its other end (#2120); scores and hours unchanged | -| 2026-10-15 | minor | Cursors of their own for 36 more tool states: node-editing cursors as the public manual describes them, Anchor Point and Curvature, Free Transform's side-shear; the rest borrow a drawn cursor until they're drawn. Busy cursor listed as missing. Every cursor is listed in `crates/tools/src/cursors.toml` | | 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged | | 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged | +| 2026-10-16 | minor | Document window scroll bars (#2322): thumb drag, track paging and the wheel over a bar; hidden in Full Screen and Presentation Mode; scores unchanged | +| 2026-10-15 | minor | Cursors of their own for 36 more tool states: node-editing cursors as the public manual describes them, Anchor Point and Curvature, Free Transform's side-shear; the rest borrow a drawn cursor until they're drawn. Busy cursor listed as missing. Every cursor is listed in `crates/tools/src/cursors.toml` | +| 2026-10-15 | minor | Artboard tool: closing a clean active tab restores the displayed board before Properties Delete. Nine targeted checks passed; three original close-route captures reviewed; scores and hours unchanged. | | 2026-10-14 | minor | Effect > Warp: style, orientation and percent controls; focused checks passed and original captures reviewed; scores and hours unchanged | | 2026-10-14 | minor | G11: open-path anchor Delete leaves separate surviving runs; dedicated Remove Anchors remains connected. Keyboard and history regression coverage added; scores and hours unchanged. | | 2026-10-14 | minor | Curvature: the Curvature tool's curve: 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°), and the ends of an open path curve in, so uneven spacing neither overshoots nor loops; the rubber band, dragged, inserted and converted points use the same rule; the Pencil's fitting unchanged (#1236, in part); scores unchanged (estimated) | | 2026-10-14 | minor | Quick Image Trace presets reveal the adjustment panel with Advanced open; Properties reopens it. Sliders keep their preset row until release so the re-trace is committed. UI regressions added; scores and remaining estimates unchanged. | | 2026-10-14 | minor | The macOS save sheet's Format menu goes back to missing: #1855 withdrew it to restore saving (#866 reopened). Rows added for the X/Y/W/H of direct-selected anchors (#1839, fixes #1824), the toolbar and All Tools drawer (#1751) and the Actions panel selection (#1853, part of #1629); the snapping row notes Snap to Grid over Snap to Pixel (#1840). Scores unchanged (estimated) | | 2026-10-14 | minor | Existing-guide Escape cancellation: eight app-input regressions, the Direct Selection Escape control and current full CI passed; scores and hours unchanged. | -| 2026-10-15 | minor | Artboard tool: closing a clean active tab restores the displayed board before Properties Delete. Nine targeted checks passed; three original close-route captures reviewed; scores and hours unchanged. | | 2026-10-14 | minor | Artboard tab changes align the tool with the displayed board; same-tab multiselection and Move/Scale Artwork options preserved. Three tab regressions and historical full CI on `a0671777` passed; scores and hours unchanged. | | 2026-10-14 | minor | Width-point finite-arithmetic follow-up: atomicity, history and native round-trip regressions and current full CI passed; scores and hours unchanged. | | 2026-10-14 | minor | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing); Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674); In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666); Escape cancels a Direct Selection drag (#1803, #1743); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); A filled Pencil path keeps a stroke of None (#1804, part of #1679); With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663); The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661); In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635); Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756); Select › Same matches type on its characters' fill and stroke (#1746, #1737); Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787); Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757); Slider rails show on Medium Dark (#1808, #1662); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation); Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); the guide-position row (#1659) is now "not scored": the documentation found doesn't establish it, and it is kept as a convenience pending the owner's decision; scores unchanged | | 2026-10-13 | minor | Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570); Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590); The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560); Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616); Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't); The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score; Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571); Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575); scores unchanged (estimated) | | 2026-10-13 | minor | G1/M11: Artboard panel deletion, protected deletion keys and navigator/tool synchronization before and after deletion; history normalization preserves valid multiselection; history regressions and full workspace CI pass; scores and hours unchanged | -| 2026-10-16 | minor | Document window scroll bars (#2322): thumb drag, track paging and the wheel over a bar; hidden in Full Screen and Presentation Mode; scores unchanged | | 2026-10-13 | minor | Alt during a drag from either ruler swaps the new guide's orientation ([public shortcut reference](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)); tool and egui event tests cover preview, snapping, release coordinates, cancellation and one-step Undo/Redo; scores unchanged (estimated). | | 2026-10-13 | minor | G1: create width points through Width Point Edit with optional adjoining adjustment; command/pointer/history regressions and full CI passed; dialog layout reviewed; scores and hours unchanged | | 2026-10-13 | minor | Corner Radius: a link toggle behind the field in the Properties and Transform panels; off, a live rectangle shows a radius field for each corner (Top Left, Top Right, Bottom Right, Bottom Left), each setting its own corner (`singleCornerRadius` preference); split from #1594; score unchanged (estimated) | | 2026-10-13 | minor | Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); correction: "Join with corner/smooth choice" was listed as done, but `path.join` and `path.average` are separate commands and Join has no such choice, so Average and Join in one step with that choice is its own row, missing; the Smooth tool smooths only the stretch of path under the drag and a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539); Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); scores unchanged (estimated) | | 2026-10-13 | minor | Sliders in Save for Web, the raster export options, Preferences and the opacity popup show their rail (#1506, fixes #1483); a template layer stays in Preview, dimmed, in Outline view (#1505, fixes #1486); Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1509, fixes #1508); a tab stop in the Tabs panel drags from where the press landed (#1487, fixes #1458); scores unchanged (estimated) | -| 2026-10-12 | minor | Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged | | 2026-10-13 | minor | Split Into Grid has its row and column sizes, gutters, totals, Add Guides and Preview (#1488, fixes #1462); Transform Objects in the Transform dialogs and Transform Each, and Reflect across an angle (#1494, fixes #1448 and #1460); the Pathfinder panel works on a group selected alone and its greyed-out buttons say what they need (#1493, for #1450); selected text in value fields is white or black on the highlight (#1490, part of #1465); a focused field follows its value when a slider or preview changes it (#1497, fixes #1457); an Alt-drag copies once per drag (#1464); scores unchanged (estimated) | +| 2026-10-12 | minor | Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged | | 2026-10-12 | minor | Transform dialogs (Move, Rotate, Scale, Reflect, Shear) preview on the canvas and step their angles and percentages with the arrow keys (#1428, fixes #1412); the transform tools, dialogs and Move act on anchors selected with Direct Selection (#1427, fixes #1408); the bounding box hides during a corner drag with the Selection tool (#1402, fixes #1229); a picture pastes when blank text sits beside it on the clipboard (#1404); scores unchanged (estimated) | | 2026-10-12 | minor | Dialogs opened from a menu take the keyboard focus every time they open (#1367); a first field that starts greyed out takes it once enabled (#1395); art flush with an artboard within a rounding error moves, copies and scales with it (#1375, from #1295); scores and hours unchanged | | 2026-10-12 | minor | Rectangular Grid and Polar Grid tool options (skew, frame, compound path, Fill Grid; #1377, in part): a skew slider under each divider count, kept as tool options and used by drags; the skew curve is our own; score unchanged (estimated) | @@ -745,8 +752,6 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | 2026-10-11 | minor | Edit Toolbar opens the All Tools drawer beside the toolbar instead of a centred dialog, dims the tools the toolbar holds, and edits the toolbar by drag and drop while it is open (#1018); score unchanged (estimated) | | 2026-10-11 | minor | Direct Selection: dragging a path selected whole by an anchor moves it whole; Alt-drag copies by an anchor too, toggles during the drag and snaps onto the original (#1104); score unchanged (estimated) | | 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation | -| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | -| 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | > **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged.