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/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 047c4530c7..55b9e344e7 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -107,6 +107,9 @@ wayland-protocols = { version = "0.32", features = ["client", "unstable"] } # wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build # through winit and rfd. wayland-backend = { version = "0.3", features = ["client_system"] } +# Waits for the screen color sampler's portal answer and for the portal leaving the bus at the same +# time (src/linux_screen_color/request.rs). Apache-2.0 OR MIT, already in the build through zbus. +futures-lite = "2" # The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the # systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 85bcc2ddfe..03a9b8848c 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -1,10 +1,10 @@ #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] use super::portal_save; -#[cfg(target_os = "linux")] -use super::tablet; #[cfg(all(windows, not(target_vendor = "win7")))] use super::windows_stylus; use super::{ClipboardProbeFactory, FilePick, PickRequest, Services, clipboard, cursor_theme, fileio, printing}; +#[cfg(target_os = "linux")] +use super::{linux_screen_color, tablet}; #[cfg(target_os = "macos")] use super::{mac_save_format, mac_screen_color, mac_tablet}; @@ -354,8 +354,10 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { .is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_))); let (clip, clip_probe) = (parent.clone(), parent.clone()); let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent); - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] let sampled_ui = ui.clone(); + #[cfg(target_os = "linux")] + let sampled_parent = p1.clone(); Services { pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())), pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))), @@ -408,6 +410,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { // Sample Color from Screen: the system's color sampler. #[cfg(target_os = "macos")] screen_color: Some(mac_screen_color::sampler(sampled_ui)), + #[cfg(target_os = "linux")] + screen_color: Some(linux_screen_color::sampler(sampled_ui, sampled_parent)), // File → Print: the system's printers and print queue. print: Some(Box::new(printing::SystemPrint)), ..Default::default() diff --git a/apps/vectorcraft/src/linux_screen_color.rs b/apps/vectorcraft/src/linux_screen_color.rs new file mode 100644 index 0000000000..0f46af19d8 --- /dev/null +++ b/apps/vectorcraft/src/linux_screen_color.rs @@ -0,0 +1,42 @@ +//! Linux: Sample Color from Screen (`vectorcraft_ui_egui::screen_color`) through the XDG desktop +//! portal's color sampler ([`portal`]). A thread of its own asks the portal and waits for the +//! person, so the UI keeps drawing; it sends the color to the UI and wakes the window. The dialog's +//! parent is the window, named on that thread as for the portal's save dialog (`portal_save`). + +mod portal; +mod request; + +/// The portal's color sampler over the window `parent` holds; `ui` is woken when it answers. +pub fn sampler(ui: egui::Context, parent: super::host_services::Parent) -> vectorcraft_ui_egui::screen_color::StartSampler { + Box::new(move || { + let (tx, rx) = std::sync::mpsc::channel(); + let (ui, window) = (ui.clone(), parent.clone()); + std::thread::Builder::new() + .name("screen-color".into()) + .spawn(move || { + // The thread names the window and holds it until the export that names it is dropped. + let (handle, export) = match window.as_deref() { + Some(window) => { + let (handle, export) = super::portal_save::exported_parent(window); + (handle, Some(export)) + } + None => (String::new(), None), + }; + let picked = portal::connect(zbus::blocking::connection::Builder::session()).and_then(|conn| portal::pick(&conn, &handle)); + drop(export); + drop(window); + let answer = picked.map_err(|failure| { + log::warn!("the portal's screen color sampler gave no color: {}", failure.detail()); + failure.message().to_string() + }); + // The UI may be gone (the app quitting): nothing waits for the color then. + let _ = tx.send(answer); + ui.request_repaint(); + }) + .map_err(|e| { + log::warn!("could not start the screen color sampler's thread: {e}"); + "the screen color sampler failed".to_string() + })?; + Ok(rx) + }) +} diff --git a/apps/vectorcraft/src/linux_screen_color/portal.rs b/apps/vectorcraft/src/linux_screen_color/portal.rs new file mode 100644 index 0000000000..b7518e1ce3 --- /dev/null +++ b/apps/vectorcraft/src/linux_screen_color/portal.rs @@ -0,0 +1,319 @@ +//! The XDG desktop portal's `Screenshot.PickColor` +//! (): +//! the call returns a request path at once, and the request's `Response` signal later carries the +//! color the person picked, as sRGB components from 0 to 1, or a code for a cancel or another end. + +use std::collections::HashMap; + +use zbus::blocking::{Connection, Proxy}; +use zbus::zvariant::{OwnedValue, Value}; + +/// How long the portal has to return a request path for `PickColor`. VectorCraft's own limit, +/// 25 seconds as libdbus's default for a method call. The portal returns the path before it shows +/// the sampler, so the person's time with the sampler does not count. +const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); + +/// Why no color came back, as the status bar shows it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Failure { + /// No session bus, no portal, or a portal without the Screenshot interface's `PickColor`. + Unavailable(String), + /// The portal did not return a request path in time, or it left the bus before it answered. + NoAnswer(String), + /// The portal ended the request without a color, or its answer can't be read. + Failed(String), +} + +impl Failure { + /// The status bar's message (English; the status bar translates it). + pub(super) fn message(&self) -> &'static str { + match self { + Failure::Unavailable(_) => "this system has no screen color sampler", + Failure::NoAnswer(_) => "the screen color sampler did not answer", + Failure::Failed(_) => "the screen color sampler failed", + } + } + + /// What went wrong, for the log. + pub(super) fn detail(&self) -> &str { + match self { + Failure::Unavailable(d) | Failure::NoAnswer(d) | Failure::Failed(d) => d, + } + } +} + +/// D-Bus errors that mean the session has no portal, or none that answers `PickColor`. +const UNAVAILABLE: &[&str] = &[ + "org.freedesktop.DBus.Error.ServiceUnknown", + "org.freedesktop.DBus.Error.NameHasNoOwner", + "org.freedesktop.DBus.Error.UnknownMethod", + "org.freedesktop.DBus.Error.UnknownInterface", + "org.freedesktop.DBus.Error.UnknownObject", +]; + +/// The connection `builder` makes, with [`CALL_TIMEOUT`] for its method calls. +pub(super) fn connect(builder: zbus::Result>) -> Result { + builder.and_then(|b| b.method_timeout(CALL_TIMEOUT).build()).map_err(|e| Failure::Unavailable(e.to_string())) +} + +/// Shows the portal's color sampler over the window `parent` names and waits for the color, `None` +/// when the person canceled it. `conn`'s method timeout bounds the call; the wait for the person +/// after it has no bound, and ends when the portal answers or leaves the bus. +pub(super) fn pick(conn: &Connection, parent: &str) -> Result, Failure> { + const DESKTOP: &str = "org.freedesktop.portal.Desktop"; + let token = super::request::token("vectorcraft_color").map_err(Failure::Failed)?; + // Made before the call: a portal can send its answer before the call returns. + let replies = super::request::Responses::new(conn).map_err(Failure::Failed)?; + let proxy = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Screenshot") + .map_err(|e| Failure::Failed(e.to_string()))?; + let options = HashMap::from([("handle_token", Value::from(token.as_str()))]); + let call = proxy.call_method("PickColor", &(parent, options)).map_err(|e| call_failure(&e))?; + let reply = replies.wait(&call).map_err(Failure::NoAnswer)?; + let (code, results): (u32, HashMap) = reply.body().deserialize().map_err(|e| Failure::Failed(e.to_string()))?; + answer(code, &results) +} + +/// The [`Failure`] for an error the `PickColor` call returned. zbus reports a call that outlives +/// the connection's method timeout as an `InputOutput` error of kind `TimedOut`. +fn call_failure(e: &zbus::Error) -> Failure { + match e { + zbus::Error::MethodError(name, ..) if UNAVAILABLE.contains(&name.as_str()) => Failure::Unavailable(e.to_string()), + zbus::Error::InputOutput(io) if io.kind() == std::io::ErrorKind::TimedOut => Failure::NoAnswer(e.to_string()), + _ => Failure::Failed(e.to_string()), + } +} + +/// The color in a `Response` with `code` and `results`: response 0 carries it, 1 is a cancel, and +/// any other code ends the request without one. The components are passed on as given; +/// `screen_color::sampled` clamps them to 0 to 1, as for the other systems' samplers. +fn answer(code: u32, results: &HashMap) -> Result, Failure> { + match code { + 0 => {} + 1 => return Ok(None), + _ => return Err(Failure::Failed(format!("the portal ended the request (response {code})"))), + } + let color = results.get("color").ok_or_else(|| Failure::Failed("no color in the portal's answer".into()))?; + // Matched field by field: zvariant's tuple conversion panics on a structure with fewer fields. + match &**color { + Value::Structure(s) => match s.fields() { + [Value::F64(r), Value::F64(g), Value::F64(b)] => Ok(Some([*r, *g, *b].map(|v| v as f32))), + _ => Err(Failure::Failed(format!("the portal's color is not three numbers: {color:?}"))), + }, + _ => Err(Failure::Failed(format!("the portal's color is not three numbers: {color:?}"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn color(rgb: (f64, f64, f64)) -> HashMap { + HashMap::from([("color".into(), OwnedValue::try_from(Value::from(rgb)).unwrap())]) + } + + fn failed(r: Result) -> bool { + matches!(r, Err(Failure::Failed(_))) + } + + #[test] + fn a_picked_color_keeps_its_srgb_values() { + assert_eq!(answer(0, &color((0.25, 0.5, 0.75))), Ok(Some([0.25, 0.5, 0.75]))); + assert_eq!(answer(0, &color((0.0, 1.0, 0.0))), Ok(Some([0.0, 1.0, 0.0]))); + // Outside 0 to 1: passed on for `screen_color::sampled` to clamp. + assert_eq!(answer(0, &color((-0.5, 1.5, 1.0))), Ok(Some([-0.5, 1.5, 1.0]))); + } + + #[test] + fn a_cancel_needs_no_color() { + assert_eq!(answer(1, &HashMap::new()), Ok(None)); + assert_eq!(answer(1, &color((0.25, 0.5, 0.75))), Ok(None)); + } + + #[test] + fn other_response_codes_are_failures() { + for code in [2, 3, u32::MAX] { + assert!(failed(answer(code, &color((0.25, 0.5, 0.75)))), "{code}"); + } + } + + #[test] + fn a_success_without_a_valid_color_is_a_failure() { + assert!(failed(answer(0, &HashMap::new()))); + let wrong = HashMap::from([("color".into(), OwnedValue::from(1_u32))]); + assert!(failed(answer(0, &wrong))); + // Fewer fields, more fields, and fields of another type: an error, not a panic. + for wrong in [Value::from((0.5, 0.5)), Value::from((0.5, 0.5, 0.5, 0.5)), Value::from((1_u32, 0.5, 0.5)), Value::from(vec![0.5, 0.5, 0.5])] { + let wrong = HashMap::from([("color".into(), OwnedValue::try_from(wrong).unwrap())]); + assert!(failed(answer(0, &wrong))); + } + } + + #[test] + fn call_errors_say_whether_the_system_has_a_sampler() { + let reply = |name: &str| { + let call = zbus::Message::method_call("/org/freedesktop/portal/desktop", "PickColor").unwrap().build(&()).unwrap(); + let error = zbus::Message::error(&call.header(), name).unwrap().build(&("detail",)).unwrap(); + zbus::Error::from(error) + }; + for name in UNAVAILABLE { + assert!(matches!(call_failure(&reply(name)), Failure::Unavailable(_)), "{name}"); + } + assert!(matches!(call_failure(&reply("org.freedesktop.portal.Error.NotAllowed")), Failure::Failed(_))); + let timed_out = zbus::Error::InputOutput(std::sync::Arc::new(std::io::ErrorKind::TimedOut.into())); + assert!(matches!(call_failure(&timed_out), Failure::NoAnswer(_))); + let broken = zbus::Error::InputOutput(std::sync::Arc::new(std::io::ErrorKind::BrokenPipe.into())); + assert!(matches!(call_failure(&broken), Failure::Failed(_))); + let messages = [Failure::Unavailable(String::new()), Failure::NoAnswer(String::new()), Failure::Failed(String::new())].map(|f| f.message()); + assert_eq!( + messages, + ["this system has no screen color sampler", "the screen color sampler did not answer", "the screen color sampler failed"] + ); + assert_eq!(call_failure(&broken).detail(), broken.to_string()); + } + + /// Every language that translates the sampler's other messages translates these too. The + /// i18n tests of `vectorcraft-ui-egui` do not read this crate's messages. + #[test] + fn the_messages_are_translated_where_the_sampler_is() { + use vectorcraft_ui_egui::i18n::{Lang, message}; + const OPEN: &str = "the screen color sampler is already open"; + let langs: Vec = Lang::all().filter(|&lang| message(lang, OPEN) != OPEN).collect(); + assert!(!langs.is_empty(), "no language translates {OPEN:?}"); + for lang in langs { + for failure in [Failure::Unavailable(String::new()), Failure::NoAnswer(String::new()), Failure::Failed(String::new())] { + assert_ne!(message(lang, failure.message()), failure.message(), "{}", lang.code()); + } + } + } + + /// A session bus of its own (`dbus-daemon`), `None` when there is none to run. + fn private_bus() -> Option<(Daemon, String)> { + use std::io::BufRead as _; + let spawning = crate::spawning(); + let daemon = std::process::Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(std::process::Stdio::piped()) + .spawn(); + drop(spawning); + let Ok(daemon) = daemon else { + eprintln!("no dbus-daemon: skipped"); + return None; + }; + let mut daemon = Daemon(daemon); + let mut address = String::new(); + std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap(); + Some((daemon, address.trim().to_string())) + } + + /// Stops the private bus when the test ends, unwinding included. + struct Daemon(std::process::Child); + + impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + /// The client's connection to the private bus at `address`, made as the sampler makes its own. + fn client(address: &str) -> Connection { + connect(zbus::blocking::connection::Builder::address(address)).unwrap() + } + + /// The portal's name, owned on the private bus at `address` before any call, so the bus never + /// starts an installed portal in its place. + fn portal(address: &str) -> Connection { + zbus::blocking::connection::Builder::address(address).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap() + } + + /// Through a real D-Bus: an answer that comes before the call returns is kept; answers on + /// other request paths or from other connections are ignored; a cancel gives no color; and a + /// portal that leaves the bus while the person picks ends the wait. + #[test] + fn the_answer_is_the_portals_response_on_the_returned_path() { + use zbus::blocking::MessageIterator; + use zbus::zvariant::OwnedObjectPath; + + let Some((_daemon, address)) = private_bus() else { return }; + let address = address.as_str(); + let server = portal(address); + let impostor = zbus::blocking::connection::Builder::address(address).unwrap().build().unwrap(); + let incoming = MessageIterator::from(&server); + let worker = std::thread::spawn(move || { + let mut calls = Vec::new(); + for msg in incoming { + let msg = msg.unwrap(); + let header = msg.header(); + if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("PickColor") { + continue; + } + let (parent, options): (String, HashMap) = msg.body().deserialize().unwrap(); + let token = String::try_from(options.get("handle_token").unwrap().try_clone().unwrap()).unwrap(); + assert!(token.starts_with("vectorcraft_color_")); + assert!(token.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')); + assert!(!calls.iter().any(|(_, old)| old == &token)); + calls.push((parent, token)); + // A portal older than version 0.9 answers on a path of its own, not the token's. + let path = format!("/org/freedesktop/portal/desktop/request/independent/pick_{}", calls.len()); + let handle = OwnedObjectPath::try_from(path.as_str()).unwrap(); + if calls.len() == 4 { + server.reply(&header, &handle).unwrap(); + assert!(server.release_name("org.freedesktop.portal.Desktop").unwrap()); + break; + } + let to = header.sender().cloned().map(zbus::names::BusName::from); + // Another connection can't end the wait by claiming the portal left. + impostor + .emit_signal( + to.clone(), + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameOwnerChanged", + &("org.freedesktop.portal.Desktop", server.unique_name().unwrap().as_str(), ""), + ) + .unwrap(); + let wrong = color((1.0, 0.0, 0.0)); + impostor.emit_signal(to.clone(), path.as_str(), "org.freedesktop.portal.Request", "Response", &(0_u32, &wrong)).unwrap(); + let unrelated = "/org/freedesktop/portal/desktop/request/unrelated"; + server.emit_signal(to.clone(), unrelated, "org.freedesktop.portal.Request", "Response", &(0_u32, &wrong)).unwrap(); + let code = if calls.len() == 2 { 1_u32 } else { 0_u32 }; + server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, &color((0.25, 0.5, 0.75)))).unwrap(); + // The reply that names the path goes last: every answer above came before it. + server.reply(&header, &handle).unwrap(); + } + calls + }); + let client = client(address); + assert_eq!(pick(&client, "x11:12"), Ok(Some([0.25, 0.5, 0.75]))); + assert_eq!(pick(&client, "wayland:example"), Ok(None)); + assert_eq!(pick(&client, ""), Ok(Some([0.25, 0.5, 0.75]))); + assert!(matches!(pick(&client, ""), Err(Failure::NoAnswer(_)))); + let calls = worker.join().unwrap(); + assert_eq!(calls.iter().map(|(parent, _)| parent.as_str()).collect::>(), ["x11:12", "wayland:example", "", ""]); + } + + /// Through a real D-Bus: a portal without `PickColor` ends the request as a system without a + /// sampler, and the sampler's connection gives the call [`CALL_TIMEOUT`]. + #[test] + fn a_portal_without_pick_color_ends_the_request() { + let Some((_daemon, address)) = private_bus() else { return }; + let address = address.as_str(); + let server = portal(address); + let incoming = zbus::blocking::MessageIterator::from(&server); + let worker = std::thread::spawn(move || { + for msg in incoming { + let msg = msg.unwrap(); + let header = msg.header(); + if header.member().map(|m| m.as_str()) == Some("PickColor") { + server.reply_error(&header, "org.freedesktop.DBus.Error.UnknownMethod", &("no PickColor",)).unwrap(); + break; + } + } + }); + let client = client(address); + assert_eq!(client.method_timeout(), Some(CALL_TIMEOUT)); + let failure = pick(&client, ""); + assert!(matches!(failure, Err(Failure::Unavailable(_))), "{failure:?}"); + worker.join().unwrap(); + } +} diff --git a/apps/vectorcraft/src/linux_screen_color/request.rs b/apps/vectorcraft/src/linux_screen_color/request.rs new file mode 100644 index 0000000000..f442b840a3 --- /dev/null +++ b/apps/vectorcraft/src/linux_screen_color/request.rs @@ -0,0 +1,127 @@ +//! The answer to the color sampler's XDG desktop portal request +//! (). +//! The wait ends with the request's `Response`, or with an error when the portal leaves the bus +//! first. + +use std::io::Read as _; + +use futures_lite::{StreamExt as _, future}; +use zbus::blocking::{Connection, MessageIterator}; +use zbus::zvariant::OwnedObjectPath; + +const DESKTOP: &str = "org.freedesktop.portal.Desktop"; + +/// A request token: `prefix`, then 128 bits from the operating system's random device, so the +/// token is unique and can't be guessed, as the Request documentation asks. +pub(super) fn token(prefix: &str) -> Result { + let mut bytes = [0; 16]; + std::fs::File::open("/dev/urandom") + .and_then(|mut file| file.read_exact(&mut bytes)) + .map_err(|e| format!("could not create a portal request token: {e}"))?; + let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + Ok(format!("{prefix}_{random}")) +} + +/// Subscriptions made before a portal method call, on every request path: a portal older than +/// version 0.9 ignores `handle_token` and answers on the request path it returns, and its answer +/// can come before the call's reply. Each stream keeps every signal from the time it is made. +pub(super) struct Responses { + replies: MessageIterator, + owners: MessageIterator, +} + +impl Responses { + pub(super) fn new(conn: &Connection) -> Result { + let reply_rule = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender(DESKTOP) + .and_then(|b| b.interface("org.freedesktop.portal.Request")) + .and_then(|b| b.member("Response")) + .map_err(|e| e.to_string())? + .build(); + let owner_rule = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender("org.freedesktop.DBus") + .and_then(|b| b.path("/org/freedesktop/DBus")) + .and_then(|b| b.interface("org.freedesktop.DBus")) + .and_then(|b| b.member("NameOwnerChanged")) + .and_then(|b| b.arg(0, DESKTOP)) + .map_err(|e| e.to_string())? + .build(); + Ok(Self { + replies: MessageIterator::for_match_rule(reply_rule, conn, None).map_err(|e| e.to_string())?, + owners: MessageIterator::for_match_rule(owner_rule, conn, None).map_err(|e| e.to_string())?, + }) + } + + /// The `Response` on the request path `call` returned, from the connection that returned it. + /// An error when that connection leaves the bus first, or the bus connection closes. + pub(super) fn wait(self, call: &zbus::Message) -> Result { + enum Event { + Reply(Option>), + Owner(Option>), + } + let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?; + let portal = call.header().sender().map(|s| s.as_str().to_string()).ok_or("the portal answered without a sender")?; + let mut replies = self.replies.into_inner(); + let mut owners = self.owners.into_inner(); + future::block_on(async { + loop { + let event = future::or(async { Event::Reply(replies.next().await) }, async { Event::Owner(owners.next().await) }).await; + match event { + Event::Reply(reply) => { + let reply = reply.ok_or("the portal closed without an answer")?.map_err(|e| e.to_string())?; + let header = reply.header(); + if header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == Some(portal.as_str()) + { + return Ok(reply); + } + } + Event::Owner(message) => { + let message = message.ok_or("the portal connection closed")?.map_err(|e| e.to_string())?; + if owner_disappeared(&message, &portal)? { + return Err("the desktop portal disappeared while waiting for its answer".into()); + } + } + } + } + }) + } +} + +/// Whether `message` is the bus's `NameOwnerChanged` saying that the connection `portal` no longer +/// owns the portal's name. The bus itself must send it: another connection can't end the wait. +fn owner_disappeared(message: &zbus::Message, portal: &str) -> Result { + if message.header().sender().map(|s| s.as_str()) != Some("org.freedesktop.DBus") { + return Ok(false); + } + let (name, old, new): (String, String, String) = message.body().deserialize().map_err(|e| e.to_string())?; + Ok(name == DESKTOP && old == portal && new != portal) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_bus_can_report_portal_owner_loss() { + let changed = |sender, name, old, new| { + zbus::Message::signal("/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged") + .unwrap() + .sender(sender) + .unwrap() + .build(&(name, old, new)) + .unwrap() + }; + let forged = changed(":1.42", DESKTOP, ":1.7", ""); + assert!(!owner_disappeared(&forged, ":1.7").unwrap()); + let real = changed("org.freedesktop.DBus", DESKTOP, ":1.7", ""); + assert!(owner_disappeared(&real, ":1.7").unwrap()); + let other = changed("org.freedesktop.DBus", "org.example.Other", ":1.7", ""); + assert!(!owner_disappeared(&other, ":1.7").unwrap()); + let old = changed("org.freedesktop.DBus", DESKTOP, ":1.8", ""); + assert!(!owner_disappeared(&old, ":1.7").unwrap()); + let acquired = changed("org.freedesktop.DBus", DESKTOP, "", ":1.7"); + assert!(!owner_disappeared(&acquired, ":1.7").unwrap()); + } +} diff --git a/apps/vectorcraft/src/mac_cursor.rs b/apps/vectorcraft/src/mac_cursor.rs index 85fea6c78a..69685a35b5 100644 --- a/apps/vectorcraft/src/mac_cursor.rs +++ b/apps/vectorcraft/src/mac_cursor.rs @@ -35,7 +35,6 @@ trait Backend { fn suspend(&mut self, window: &Self::Window); fn restore(&mut self, window: &Self::Window); fn build(&mut self, request: &Request) -> Option; - fn current(&self) -> Option; fn set(&mut self, cursor: &Self::Cursor); } @@ -73,9 +72,10 @@ impl State { && *key == request.key && *owned == window { - if self.backend.current().as_ref() != Some(cursor) { - self.backend.set(cursor); - } + // AppKit can show the system arrow when the pointer moves while + // `NSCursor.currentCursor` (the app's last set cursor) still reads as ours, so a + // comparison would keep the arrow. Setting the cursor again costs nothing. + self.backend.set(cursor); return; } self.release(); @@ -135,9 +135,6 @@ impl Backend for AppKit { fn build(&mut self, request: &Request) -> Option { build(&request.bitmap) } - fn current(&self) -> Option { - Some(NSCursor::currentCursor()) - } fn set(&mut self, cursor: &Self::Cursor) { cursor.set(); } diff --git a/apps/vectorcraft/src/mac_cursor/tests.rs b/apps/vectorcraft/src/mac_cursor/tests.rs index 648492c4b2..ad13a11336 100644 --- a/apps/vectorcraft/src/mac_cursor/tests.rs +++ b/apps/vectorcraft/src/mac_cursor/tests.rs @@ -6,7 +6,6 @@ use std::cell::Cell; struct Fake { window: Option, suspended: Vec, - current: Option, sets: usize, builds: usize, restores: Vec, @@ -27,17 +26,12 @@ impl Backend for Fake { fn restore(&mut self, w: &u8) { self.suspended.retain(|v| v != w); self.restores.push(*w); - self.current = None; } fn build(&mut self, r: &Request) -> Option { self.builds += 1; (!self.fail).then_some(r.key) } - fn current(&self) -> Option { - self.current - } - fn set(&mut self, cursor: &Key) { - self.current = Some(*cursor); + fn set(&mut self, _: &Key) { self.sets += 1; } } @@ -66,19 +60,16 @@ fn deliver(state: &mut State, request: Option) { } #[test] -fn mac_cursor_ownership_deduplicates_reasserts_and_restores() { +fn mac_cursor_ownership_reasserts_every_pass_and_restores() { let req = request(1.0); let mut state = fake_state(); deliver(&mut state, Some(req.clone())); assert_eq!(state.backend.suspended, [1]); assert_eq!((state.backend.builds, state.backend.sets), (1, 1)); + // The pointer moves and AppKit shows the system arrow, but the app's current cursor + // still reads as ours. The next pass sets it again without rebuilding or re-suspending. deliver(&mut state, Some(req.clone())); - assert_eq!(state.backend.sets, 1); - // Simulate a platform fallback replacing the cursor after finalization. The next pass - // reasserts; this proves the protocol, not AppKit timing or absence of transient flicker. - state.backend.current = None; - deliver(&mut state, Some(req.clone())); - assert_eq!(state.backend.sets, 2); + assert_eq!((state.backend.builds, state.backend.sets, state.backend.suspended.len()), (1, 2, 1)); deliver(&mut state, None); assert_eq!(state.backend.restores, [1]); deliver(&mut state, Some(req.clone())); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 33e03b1f72..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 930938e03f..a9bf5d1009 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -242,6 +242,13 @@ fn parent(window: &winit::window::Window) -> (String, Option) { } } +/// [`parent`] for another dialog of the portal (the screen color sampler, `linux_screen_color`): +/// the identifier stays valid until the second value is dropped. +#[cfg(target_os = "linux")] +pub(crate) fn exported_parent(window: &winit::window::Window) -> (String, impl Send + 'static) { + parent(window) +} + /// The portal's identifier of `window`: its X11 id, else empty (no Wayland export is made off /// Linux). #[cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] diff --git a/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/live/expand.rs b/crates/doc/src/live/expand.rs index 7c80866ee7..f4a601c7b5 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -119,7 +119,8 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node { { out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps)); } - if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) { + // A linked opacity mask bends with the object it masks; an unlinked one stays where it is. + if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()).filter(|m| m.linked) { m.art = Arc::new(map_node(&m.art, w)); } match &mut out.kind { diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 37c76c5e3a..fcaa78e494 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -57,7 +57,9 @@ pub struct CharStyle { #[serde(default)] pub all_caps: bool, /// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns - /// a feature on, `"-liga"` off. + /// a feature on, `"-liga"` off, and `"salt=2"` turns a feature with alternates on at the font's + /// alternate number 2 (the value the shaper gets; a plain `"salt"` is value 1). Runs that + /// differ only in this list are shaped together, each with its own features. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub features: Vec, /// Character style (Character Styles panel) these attributes come from; None = Normal. diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 16e893ce99..a7fb05b2a6 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -690,6 +690,525 @@ fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { } } +/// A white 10 pt square with id `id` at the origin. +fn white_square(id: u64) -> Node { + Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)) +} + +/// `n` masked by `art`, linked or not. +fn masked_by(mut n: Node, art: Node, linked: bool) -> Node { + let mut mask = vectorcraft_doc::OpacityMask::new(art, true); + mask.linked = linked; + n.extras_mut().mask = Some(Box::new(mask)); + n +} + +/// The geometry effects that reshape a group's members, one at a time. +fn reshaping_effects() -> Vec { + let fx = |id: &str, params: serde_json::Value| Effect { id: id.into(), params, visible: true }; + let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}); + vec![ + fx("distort.transform", serde_json::json!({"moveH": 30})), + fx("distort.transform", serde_json::json!({"rotate": 90})), + fx("distort.twist", serde_json::json!({"angle": 90})), + fx("distort.freeDistort", corners), + fx("warp.arc", serde_json::json!({})), + fx("distort.roughen", serde_json::json!({"size": 5, "seed": 3})), + fx("distort.zigZag", serde_json::json!({})), + fx("distort.puckerBloat", serde_json::json!({"amount": 50})), + fx("distort.tweak", serde_json::json!({})), + fx("path.offsetPath", serde_json::json!({"offset": 3})), + fx("stylize.roundCorners", serde_json::json!({"radius": 3})), + ] +} + +/// A group's geometry effect reshapes the art of a member's linked opacity mask as it reshapes the +/// member: a mask drawn over the member still covers it. An unlinked mask stays where it is. +#[test] +fn a_groups_geometry_effects_reshape_a_members_linked_mask_with_it() { + let square = white_square(1); + let original = square.path_data().cloned(); + for e in reshaping_effects() { + for linked in [true, false] { + let member = masked_by(square.clone(), white_square(4), linked); + let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + let mask = member.extras().mask.as_deref().unwrap(); + assert!(member.path_data().cloned() != original, "{}: the member is reshaped", e.id); + let want = if linked { member.path_data().cloned() } else { original.clone() }; + assert_eq!(mask.art.path_data().cloned(), want, "{} (linked: {linked})", e.id); + assert_eq!(mask.linked, linked); + } + } +} + +/// The mask of a group inside the group, the mask of a path in it, a mask inside that mask's art and +/// an inverted mask all take the outer group's effect with the paths they mask. +#[test] +fn a_groups_geometry_effects_reshape_nested_and_inverted_masks() { + for e in reshaping_effects() { + let innermost = masked_by(white_square(7), white_square(8), true); + let path = masked_by(white_square(1), masked_by(white_square(6), innermost, true), true); + let mut inner = masked_by(Node::group(NodeId(5), vec![Arc::new(path)]), white_square(4), true); + if let Some(m) = inner.extras_if_any().and_then(|x| x.mask.as_mut()) { + m.invert = true; + } + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let path = &inner.children().unwrap()[0]; + let want = path.path_data().cloned(); + let mask_of = |n: &Node| n.extras().mask.as_deref().map(|m| (*m.art).clone()).unwrap(); + let (outer_mask, path_mask) = (mask_of(inner), mask_of(path)); + assert!(inner.extras().mask.as_deref().is_some_and(|m| m.invert)); + assert_eq!(outer_mask.path_data().cloned(), want, "{}: the inner group's inverted mask", e.id); + assert_eq!(path_mask.path_data().cloned(), want, "{}: the path's mask", e.id); + assert_eq!(mask_of(&path_mask).path_data().cloned(), want, "{}: the mask inside the mask's art", e.id); + assert_eq!(mask_of(&mask_of(&path_mask)).path_data().cloned(), want, "{}: one level deeper", e.id); + } +} + +/// A Transform effect's copies of a path with a linked mask are objects of their own, each with the +/// mask moved with it; the original keeps its mask where it was. +#[test] +fn each_transform_copy_of_a_masked_member_carries_its_mask() { + let member = masked_by(white_square(1), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(member)]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); + assert_eq!(copies.len(), 3); + for c in copies { + let mask = c.extras().mask.as_deref().unwrap(); + assert_eq!(mask.art.geometric_bounds(), c.geometric_bounds()); + } +} + +/// A Transform effect's copies of the paths of a member group give the group's linked mask the same +/// copies: the mask covers each copy. +#[test] +fn a_member_groups_mask_takes_the_transform_copies_of_its_paths() { + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 1}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let want = inner.geometric_bounds(); + assert_eq!(want, Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "the path and its copy"); + assert_eq!(inner.extras().mask.as_deref().unwrap().art.geometric_bounds(), want); +} + +/// A mask's art of several objects takes Transform copies whole, as the art it masks does: each copy +/// of the mask's art is a group of its objects, so the copies do not interleave object by object. +#[test] +fn a_masks_art_of_several_objects_is_copied_whole() { + let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 1}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art; + let copies = mask.children().unwrap(); + assert_eq!(copies.len(), 2, "the mask's art and its copy"); + for c in copies { + let ids: Vec = c.children().into_iter().flatten().map(|m| m.id).collect(); + assert_eq!(ids, [NodeId(4), NodeId(5)], "each copy holds the mask's objects"); + } + assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0))); +} + +/// The linked mask of a mask's art that is copied whole takes the same Transform copies. +#[test] +fn the_mask_of_a_masks_art_copied_whole_takes_its_copies() { + let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]); + let mask_art = masked_by(parts, white_square(6), true); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 1}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0)), "the mask's art and its copy"); + let own = &mask.extras().mask.as_deref().expect("the mask's art keeps its own mask").art; + assert_eq!(own.geometric_bounds(), Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "its mask covers the copy too"); +} + +/// The mask of a mask's art copied whole stays in the budget while that art is copied: when the +/// budget has no room for that mask's copy, the art keeps its shape with its masks. +#[test] +fn the_mask_of_a_masks_art_copied_whole_counts_against_the_budget() { + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true }; + let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]); + let mask_art = masked_by(parts, white_square(6), true); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut art = Node::group(NodeId(10), vec![Arc::new(inner)]); + let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines; + // Room for the four squares and the copies of three of them, not for the copy of the fourth. + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 7 * one }; + crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None); + let inner = &art.children().unwrap()[0]; + let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0)); + assert_eq!(inner.geometric_bounds(), square, "the path keeps its shape"); + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 15.0, 10.0)), "and so does its mask"); +} + +/// When a member group's linked mask can't take all the Transform copies its paths take (past the +/// budget), the art keeps its shape under the effect, so the mask still covers the paths. +#[test] +fn art_whose_mask_would_take_fewer_copies_keeps_its_shape() { + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true }; + let member = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true); + let mut art = Node::group(NodeId(10), vec![Arc::new(member)]); + let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines; + // Room for the path, its mask and a copy of the path, not for a copy of the mask. + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 3 * one }; + crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None); + let member = &art.children().unwrap()[0]; + let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0)); + assert_eq!(member.geometric_bounds(), square, "the path keeps its shape"); + assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), square, "and so does its mask"); +} + +/// The copies of a path with a linked mask count the art of the mask each copy carries against the +/// budget: they stop where the masks would pass it. +#[test] +fn transform_copies_count_the_art_of_their_masks() { + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 2}), visible: true }; + let member = masked_by(white_square(1), white_square(4), true); + let mut art = Node::group(NodeId(10), vec![Arc::new(member)]); + let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines; + // Room for the path, its mask and one copy of both, not for a second copy. + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 4 * one }; + crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None); + let copies = art.children().unwrap()[0].children().expect("the object and its copy are objects of their own"); + assert_eq!(copies.len(), 2); + for c in copies { + assert_eq!(c.extras().mask.as_deref().unwrap().art.geometric_bounds(), c.geometric_bounds()); + } +} + +/// A Transform effect that scales strokes moves a group's paths as objects, their masks with them. +/// The mask of a member group, whose art holds a path with a mask of its own, moves with the group's +/// paths too. +#[test] +fn a_transform_that_moves_paths_as_objects_moves_a_member_groups_nested_mask() { + let mask_art = masked_by(white_square(4), white_square(5), true); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(3, 20.0, Color::BLACK)]); + let params = serde_json::json!({"moveH": 30, "strokes": true}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let want = inner.children().unwrap()[0].geometric_bounds(); + assert_eq!(want, Some(Rect::new(30.0, 0.0, 40.0, 10.0))); + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), want, "the group's mask"); + assert_eq!(mask.extras().mask.as_deref().unwrap().art.geometric_bounds(), want, "the mask inside its art"); +} + +/// A group's Transform effect moves an image member's linked opacity mask with the image, and its +/// copies' masks with the copies; an unlinked mask stays. +#[test] +fn a_groups_transform_moves_an_image_members_linked_mask() { + for linked in [true, false] { + for copies in [0, 2] { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), white_square(4), linked); + let mut g = Node::group(NodeId(10), vec![Arc::new(image)]); + let params = serde_json::json!({"moveH": 30, "copies": copies}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let first = &art.children().unwrap()[0]; + let images: Vec<&Node> = match first.children() { + Some(ch) => ch.iter().map(|c| &**c).collect(), + None => vec![first], + }; + assert_eq!(images.len(), copies + 1); + for im in images { + let mask = im.extras().mask.as_deref().unwrap(); + let want = if linked { im.geometric_bounds() } else { Some(Rect::new(0.0, 0.0, 10.0, 10.0)) }; + assert_eq!(mask.art.geometric_bounds(), want, "linked {linked}, copies {copies}"); + } + } + } +} + +/// When a path in a member's mask art would pass the effects' budget, the whole mask stays as it +/// was, rather than part of its art moving with the member, and the member keeps its shape with it. +#[test] +fn a_mask_whose_art_passes_the_budget_stays_whole() { + // Bloat bends a path's sides into curves drawn with more lines the larger they are: many more + // on the mask's large square. + let bloat = Effect { id: "distort.puckerBloat".into(), params: serde_json::json!({"amount": 50}), visible: true }; + let large = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0)), Appearance::default()); + let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), Arc::new(large.clone())]); + let member = masked_by(white_square(1), mask_art.clone(), true); + let mut art = Node::group(NodeId(10), vec![Arc::new(member)]); + // Room for the member and the mask's small square to take the effect, not for the large one. + let bounds = art.geometric_bounds().unwrap(); + let ctx = crate::GeomContext { seed: 10, ..crate::GeomContext::of(&art) }; + let square = white_square(1).path_data().cloned().unwrap(); + let made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), &square, bounds, &ctx)).lines; + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 2 * made + crate::Budget::of(large.path_data().unwrap()).lines }; + let large_made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), large.path_data().unwrap(), bounds, &ctx)).lines; + assert!(large_made > crate::Budget::of(large.path_data().unwrap()).lines, "the large square's curves cost more lines"); + crate::reshape::reshape_leaves_within(&mut art, &[&bloat], budget, None); + let member = &art.children().unwrap()[0]; + assert_eq!(member.path_data(), Some(&square), "the member keeps its shape with its mask"); + assert_eq!(*member.extras().mask.as_deref().unwrap().art, mask_art, "the mask stays whole"); +} + +/// Live objects in a member's linked mask are evaluated before the group's geometry effects run +/// over the mask's art, so the mask still covers the member: a Repeat in it moves and bends with +/// the member. +#[test] +fn a_groups_effects_reshape_a_live_object_in_a_members_mask() { + let mut grid = vectorcraft_doc::RepeatSpec::grid(vec![Arc::new(white_square(5))], 0.0, 0.0); + if let vectorcraft_doc::RepeatKind::Grid { rows, cols, .. } = &mut grid.kind { + (*rows, *cols) = (1, 1); + } + let repeat = Node::new(NodeId(4), NodeKind::Repeat(grid)); + let drawn: Vec = crate::expand_live(None, &repeat); + let leaf = |n: &Node| { + let mut found = None; + n.walk(&mut |c| found = found.take().or_else(|| c.path_data().cloned())); + found + }; + assert_eq!(drawn.iter().find_map(leaf), white_square(1).path_data().cloned(), "the Repeat draws one square over the member"); + for e in [reshaping_effects()[0].clone(), reshaping_effects()[4].clone()] { + let member = masked_by(white_square(1), repeat.clone(), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + let mask = &member.extras().mask.as_deref().unwrap().art; + assert!(member.path_data() != white_square(1).path_data(), "{}: the member is reshaped", e.id); + assert_eq!(leaf(mask), member.path_data().cloned(), "{}: the mask's square takes the effect with it", e.id); + } +} + +/// Type in a member's linked mask is outlined before the group's Transform effect moves the mask's +/// art with the member. +#[test] +fn a_groups_transform_moves_type_in_a_members_mask() { + if skip_without_craft_fonts() { + return; + } + let member = masked_by(white_square(1), type_h(), true); + let before = crate::reshape::outline_text(&type_h()).unwrap().geometric_bounds().unwrap(); + let mut g = Node::group(NodeId(10), vec![Arc::new(member)]); + g.appearance.effects.push(reshaping_effects()[0].clone()); + let art = evaluate_container(None, &g).unwrap(); + let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art; + let mut text = false; + mask.walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_))); + assert!(!text, "the type is outlined"); + let after = mask.geometric_bounds().unwrap(); + assert!((after.x0 - before.x0 - 30.0).abs() < 0.5 && (after.y0 - before.y0).abs() < 0.5, "{before:?} -> {after:?}"); +} + +/// The linked mask of a compound path in a group, and of a path in a layer, takes the geometry +/// effects with the art it masks. +#[test] +fn a_compound_paths_and_a_layers_members_masks_take_the_effects() { + let geometry = |n: &Node| crate::reshape::leaf_geometry(n).map(|(p, _)| p); + for e in reshaping_effects() { + let square = white_square(1); + let compound = Node::new(NodeId(1), NodeKind::Compound { children: vec![Arc::new(square.clone())], rule: FillRule::NonZero }); + let compound = masked_by(compound, white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(compound), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + assert!(geometry(member) != square.path_data().cloned(), "{}: the compound path is reshaped", e.id); + assert_eq!(geometry(&member.extras().mask.as_deref().unwrap().art), geometry(member), "{}: compound path", e.id); + + let mut layer = Node::layer(NodeId(10), "Layer", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + ch.extend([Arc::new(masked_by(square.clone(), white_square(4), true)), sq(2, 20.0, Color::BLACK)]); + } + layer.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &layer).unwrap(); + let member = &art.children().unwrap()[0]; + assert!(member.path_data() != square.path_data(), "{}: the layer's path is reshaped", e.id); + assert_eq!(member.extras().mask.as_deref().unwrap().art.path_data(), member.path_data(), "{}: layer", e.id); + } +} + +/// A group's Transform effect moves a symbol instance member's linked mask with the instance. +#[test] +fn a_groups_transform_moves_a_symbol_instance_members_linked_mask() { + let instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(instance, white_square(4), true)), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[0].clone()); + let art = evaluate_container(None, &g).unwrap(); + let instance = &art.children().unwrap()[0]; + let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("an instance: {instance:?}") }; + assert!((xf.translation().x - 30.0).abs() < 1e-9, "the instance moves: {xf:?}"); + let mask = instance.extras().mask.as_deref().unwrap().art.geometric_bounds().unwrap(); + assert!((mask.x0 - 30.0).abs() < 1e-9 && (mask.x1 - 40.0).abs() < 1e-9, "its mask moves with it: {mask:?}"); +} + +/// With Scale Strokes & Effects on, a group's Transform effect scales the stroke of an image +/// member's linked mask as it scales that of a path member's mask. +#[test] +fn a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike() { + let stroked = + Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0)); + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), stroked.clone(), true); + let path = masked_by(white_square(2), stroked, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(image), Arc::new(path)]); + let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "strokes": true}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let width = |n: &Node| n.extras().mask.as_deref().unwrap().art.appearance.stroke().map(|s| s.width); + let ch = art.children().unwrap(); + assert_eq!(width(&ch[1]), Some(4.0), "the path's mask"); + assert_eq!(width(&ch[0]), width(&ch[1]), "the image's mask"); +} + +/// With Transform Objects off and Scale Strokes & Effects on, a group's Transform effect scales the +/// strokes of a member's linked mask as it scales the member's, and moves neither. +#[test] +fn a_transform_of_strokes_only_scales_a_members_mask_strokes() { + let stroked = |id: u64| { + Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0)) + }; + let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(stroked(1), stroked(4), true))]); + let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + let mask = &member.extras().mask.as_deref().unwrap().art; + assert_eq!(member.appearance.stroke().map(|s| s.width), Some(4.0), "the member's stroke"); + assert_eq!(mask.appearance.stroke().map(|s| s.width), Some(4.0), "the mask's stroke"); + assert_eq!(mask.geometric_bounds(), member.geometric_bounds()); +} + +/// The art of a member group's mask is copied as it was before the first effect: a mask of one +/// path takes a second Transform effect's copies path by path, as the path it masks does, though +/// the first effect (with Scale Strokes & Effects) made it a group of copies. +#[test] +fn a_masks_art_is_copied_as_it_was_before_the_first_effect() { + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + for strokes in [true, false] { + let params = serde_json::json!({"moveH": 30, "copies": 2, "strokes": strokes}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + } + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), inner.geometric_bounds()); + let kinds = |n: &Node| n.children().unwrap().iter().map(|c| std::mem::discriminant(&c.kind)).collect::>(); + let paths = &inner.children().unwrap()[0]; + assert_eq!(kinds(paths).len(), 3, "the first effect's three paths"); + assert!(paths.children().unwrap().iter().all(|c| matches!(c.kind, NodeKind::Compound { .. })), "each with its copies"); + assert_eq!(kinds(mask), kinds(paths), "the mask's art takes the copies as the paths do"); +} + +/// A 10 × 10 image with id `id` at the origin. +fn image_node(id: u64) -> Node { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + Node::new(NodeId(id), NodeKind::Image(Box::new(im))) +} + +/// A linked mask whose art is an image moves with the path it masks under a group's Transform +/// effect, and a member group's image mask takes the copies its paths take. +#[test] +fn a_groups_transform_moves_a_linked_mask_whose_art_is_an_image() { + let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(white_square(1), image_node(4), true)), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[0].clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + assert_eq!(member.geometric_bounds(), Some(Rect::new(30.0, 0.0, 40.0, 10.0))); + assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), member.geometric_bounds()); + + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), image_node(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + assert_eq!(inner.geometric_bounds(), Some(Rect::new(0.0, 0.0, 70.0, 10.0))); + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), inner.geometric_bounds(), "the image and its copies"); +} + +/// An image in a linked mask's art takes no geometry effect but Transform, so under a Warp effect +/// the art keeps its shape with its mask. +#[test] +fn art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp() { + let member = masked_by(white_square(1), Node::group(NodeId(3), vec![Arc::new(image_node(4))]), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[4].clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + assert_eq!(member.path_data(), white_square(1).path_data(), "the path keeps its shape"); + assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0))); +} + +/// A Warp effect bends an image's pixels but not the art of its linked mask, so art with such an +/// image keeps its shape under the warp, with the image and its mask. +#[test] +fn art_with_a_masked_image_keeps_its_shape_under_a_warp() { + let image = masked_by(image_node(1), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(image.clone()), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[4].clone()); + let art = evaluate_container(None, &g).unwrap(); + let ch = art.children().unwrap(); + assert_eq!(*ch[0], image, "the image and its mask"); + assert_eq!(ch[1].path_data(), sq(2, 20.0, Color::BLACK).path_data(), "the path keeps its shape"); +} + +/// A symbol instance in perspective projects the linked masks of the objects in its art with them. +#[test] +fn perspective_projects_the_masks_in_a_symbols_art() { + let art = Node::group(NodeId(20), vec![Arc::new(masked_by(white_square(21), white_square(22), true))]); + let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let mut at = vectorcraft_doc::PerspectiveAttachment::new("left", 0.0); + at.projection = Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.01, 0.0, 1.0]); + instance.extras_mut().perspective = Some(Box::new(at)); + let out = crate::reshape::reshape(None, &instance, Some(&art)).unwrap(); + let mut masked = vec![]; + out.walk(&mut |c| masked.extend(c.extras().mask.as_deref().map(|m| (c.path_data().cloned(), m.art.path_data().cloned())))); + let [(path, mask)] = masked.as_slice() else { panic!("one masked path: {masked:?}") }; + assert!(path != &white_square(21).path_data().cloned(), "the path is projected"); + assert_eq!(mask, path, "and so is its mask"); +} + /// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit /// pitch, each crossing every bar of the other direction: n² crossings. fn crossing_bars(n: usize, effect: &str) -> Node { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 9d93f82a79..483ac44d6e 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -12,7 +12,7 @@ //! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives //! them ([`image_rows`]). -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -590,7 +590,7 @@ fn as_art_opts( transform_items(&mut art, paint_doc); // Type and symbols in perspective: the art projected onto their plane. if let Some(h) = n.projection() { - project_leaves(&mut art, &h); + project_leaves(&mut art, &h, paint_doc); } // The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip // group). @@ -608,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); @@ -687,58 +707,151 @@ fn hoist_copies(art: &mut Node) { /// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused). /// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect /// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when -/// `owner` does not hold it. +/// `owner` does not hold it. The art of the linked opacity masks of the objects in `art` (not of +/// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]), with the +/// type and live objects in it outlined and evaluated first ([`outlined_members`]). When a mask +/// stays as it was, or a Transform effect makes fewer copies than it asks for, art with linked +/// masks keeps its shape under that effect. fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry { let mut whole = crate::Carry::default(); if fx.is_empty() { return whole; } - let mut leaves_safe = true; - for_each_leaf(art, &mut |leaf| { - if !leaves_safe { - return; - } - if let Some((_, rule)) = leaf_geometry(leaf) { - let has_stroke = leaf.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); - if rule != FillRule::NonZero || has_stroke { - leaves_safe = false; - } - } - }); + let masked = has_linked_mask(art, false); + if masked { + each_linked_mask_mut(art, false, 0, &mut |m| *m = outlined_mask_art(doc, m)); + } + let mut masks_safe = true; + each_linked_mask(art, false, 0, &mut |_, m| masks_safe &= leaves_safe(m)); let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); - let style_safe = leaves_safe && !art_stroke; + let style_safe = leaves_safe(art) && masks_safe && !art_stroke; let listed = fx; let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe); let fx = opt_fx.as_slice(); let seed = art.id.0; let mut used = art_cost(art); + each_linked_mask(art, false, 0, &mut |_, m| used = used.plus(art_cost(m))); let max = budget.at_least(used); - // Read before the effects run: a path's own copies don't make it art of several objects. + // Read before the effects run: a path's own copies don't make it art of several objects. The + // same holds for the art of each mask, listed by the object it masks. let copy_whole = copies_whole(art); + let mut wholes = HashMap::new(); + each_linked_mask(art, false, 0, &mut |id, m| { + wholes.entry(id).or_insert_with(|| copies_whole(m)); + }); let mut warped = WarpedImages::new(); for e in fx { let Some(bounds) = warped.bounds(art) else { break }; let params = merged_params(&e.id, &e.params); // Where a live-effect plug-in sits, which it is listed by when it is deferred. let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e))); + let step = Step { e, params: ¶ms, bounds, seed, effect, apart, doc, wholes: &wholes }; + // Art with linked masks keeps its shape under an effect that a mask in it could not take + // as the art it masks did, so no mask is left apart from its art. + let before = masked.then(|| (art.clone(), used)); + let ran = step.run(art, max, &mut used, copy_whole, Some(&mut warped), false, 0); + if (ran.masks || ran.short) + && let Some((was, cost)) = before + { + *art = was; + used = cost; + continue; + } + if let Some(carry) = ran.whole { + whole.push(carry); + } + } + cut_warped_images(art, &warped); + whole +} + +/// One geometry effect of an object run over its art ([`reshape_leaves_apart`]): the effect, its +/// merged params, the reference box and seed of the object's art, where a deferred live-effect +/// plug-in is listed, whether each Transform copy becomes an object, the document that the +/// swatches and patterns of painted pieces are looked up in, and [`copies_whole`] of the art of +/// each linked mask before the first effect, by the id of the object it masks. +struct Step<'a> { + e: &'a Effect, + params: &'a serde_json::Value, + bounds: Rect, + seed: u64, + effect: Option, + apart: bool, + doc: Option<&'a Document>, + wholes: &'a HashMap, +} + +/// What [`Step::run`] did to a piece of art. +struct Ran { + /// A path in the art kept its shape (past the budget). + kept: bool, + /// The linked opacity mask of an object in the art stayed as it was while the paths it masks + /// took the effect, or could have ([`Step::reshape_masks`]). + masks: bool, + /// A Transform effect made fewer copies of a path, image or symbol instance than it asks for + /// (past the budget or out of reach), where a linked mask could take a different number. + short: bool, + /// How what paints the art as a whole follows the effect, when it does. + whole: Option, +} + +impl Ran { + /// Nothing in the art kept its shape or made fewer copies, and every linked mask in it moved. + fn complete(&self) -> bool { + !self.kept && !self.masks && !self.short + } +} + +impl Step<'_> { + /// Run the effect over `art`, whose paths and those of the linked opacity masks in it keep to + /// `max` together with what they cost so far (`used`). `copy_whole` is [`copies_whole`] of the + /// art, or of a mask's art, before the first effect. `warped` lists the images Warp effects + /// bend in the art ([`WarpedImages`]); a mask's art has none. With `own`, `art` is the art of a + /// mask, and its own linked mask takes the effect too. `depth` counts the masks `art` is in. A + /// live-effect plug-in that paints its pieces leaves a mask's art, and art that holds objects + /// with linked masks, as they are and reports the masks as stayed: a mask can't take the + /// pieces as the art it masks does. So does a Warp effect that bends an image with a linked + /// mask. + #[allow(clippy::too_many_arguments)] + fn run( + &self, + art: &mut Node, + max: Budget, + used: &mut Budget, + copy_whole: bool, + mut warped: Option<&mut WarpedImages>, + own: bool, + depth: usize, + ) -> Ran { + let (e, params, bounds, seed) = (self.e, self.params, self.bounds, self.seed); + let stayed = Ran { kept: false, masks: true, short: false, whole: None }; if let Some(plugin) = painted_plugin(e) { - let at = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); + if own || has_linked_mask(art, false) { + return stayed; + } + let at = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); for_each_leaf(art, &mut |leaf| { let Some((path, _)) = leaf_geometry(leaf) else { return }; let before = Budget::of(&path); let room = max.minus(used.minus(before)); - if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc, at) { - used = used.minus(before).plus(made); + if let Some(made) = paint_leaf(leaf, plugin, params, bounds, room, self.doc, at) { + *used = used.minus(before).plus(made); } }); - continue; + return Ran { kept: false, masks: false, short: false, whole: None }; + } + // A Warp effect that bends images ([`mark_warped_images`]) bends an image's pixels, not the + // art of its linked mask. + let warp = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id).filter(|_| crate::warp::bends(&e.id, params)); + if warp.is_some() && has_masked_image(art) { + return stayed; } // Placed gradients follow a Transform effect, Free Distort and the Warp effects // ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from // its bounds before the effect to its bounds after it. Under those and the other effects, // the placed gradients of type's outlines in `art` (`outline_text`) move and scale with // the outlines' bounds. - let follows = crate::Carry::follows(&e.id, ¶ms, bounds, seed); + let follows = crate::Carry::follows(&e.id, params, bounds, seed); let boxes = follows && crate::Carry::by_bounds(&e.id); let mut outlines = vec![]; if !follows || boxes { @@ -754,30 +867,61 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget // The images earlier Warps bend are measured by their pieces only when a group's // gradient needs the bounds. if !groups.is_empty() - && let Some(images) = warped.when_cut(art) + && let Some(images) = warped.as_deref().and_then(|w| w.when_cut(art)) { groups.clear(); group_marks(art, &mut vec![], &HashSet::new(), Some(images), &mut groups); } } // The ids of the paths that kept their shape (past the budget), and their places in the - // order [`for_each_leaf`] visits the paths. + // order [`for_each_leaf`] visits the paths; and the ids of the paths whose linked masks + // moved with them as they were transformed as objects. let mut kept_ids = HashSet::new(); let mut kept_leaves = HashSet::new(); let mut leaf_at = 0usize; + let mut moved_ids = HashSet::new(); let transform = (e.id == "distort.transform").then(|| { - let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); - let options = crate::distort::TransformOptions::of(¶ms); - (m, copies, options, crate::distort::transform_affine(bounds, ¶ms, seed)) + let (m, copies) = crate::distort::transform_map(bounds, params, seed); + let options = crate::distort::TransformOptions::of(params); + (m, copies, options, crate::distort::transform_affine(bounds, params, seed)) }); + // In a mask's art, images and symbol instances take only a Transform effect + // ([`transform_placed`]): a mask's art that holds one can't take another effect as the art + // it masks does. + if own && transform.is_none() && (is_placed(art) || placed_count(art) > 0) { + return Ran { kept: false, masks: true, short: false, whole: None }; + } // Art of several objects is copied whole, so the copies don't interleave member by member. + // The copies of its members carry their linked masks, moved with them. if let Some((m, copies, options, _)) = transform && copies > 0 && copy_whole { - used = copy_art(art, m, copies, copy_scaling(options), max); - continue; + if !own { + *used = copy_art(art, m, copies, copy_scaling(options), max); + return Ran { kept: false, masks: false, short: false, whole: None }; + } + // A mask's art: its copies keep to what the rest of the art leaves of `max`, and the + // linked mask of the art itself takes them too. That mask's art stays in `used`, as + // [`copy_art`] counts only the members. + let before = copy_cost(art); + let rest = used.minus(before).plus(art.extras().mask.as_deref().map_or(Budget { anchors: 0, lines: 0 }, |k| copy_cost(&k.art))); + let made = copy_art(art, m, copies, copy_scaling(options), max.minus(rest)); + *used = rest.plus(made); + let short = art.children().is_some_and(|c| c.iter().filter(|c| !c.is_guide()).count() <= copies); + let none = HashSet::new(); + let masks = self.reshape_own_mask(art, &Skip { moved: &none, kept: &none }, max, used, depth); + return Ran { kept: false, masks, short, whole: None }; } + // A shortfall in the copies matters for the objects a linked mask above them covers, which + // the mask would take a different number of copies than, and anywhere in a mask's art. + let watch = transform.is_some_and(|(_, copies, options, _)| copies > 0 && options.objects) && (own || has_linked_mask(art, false)); + let mut covered = HashSet::new(); + if watch && !own { + masked_ids(art, false, &mut covered); + } + let counts = |id: NodeId| watch && (own || covered.contains(&id)); + let mut short = false; for_each_leaf(art, &mut |leaf| { let at = leaf_at; leaf_at += 1; @@ -790,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; @@ -806,20 +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); } @@ -832,36 +995,230 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget // Warp bends images too: each one's pixels follow the warp's map over the same box. The // images are cut into pieces once, after the last effect ([`cut_warped_images`]). An image // whose clipping frame kept its shape (past the budget) keeps its pixels as they are. - if let Some(style) = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id) - && crate::warp::bends(&e.id, ¶ms) + if let Some(style) = warp + && let Some(warped) = warped.as_deref_mut() { - let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, ¶ms)); + let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, params)); warped.reserve(art); - mark_warped_images(art, &f, &kept_leaves, false, &mut 0, &mut warped); + mark_warped_images(art, &f, &kept_leaves, false, &mut 0, warped); } if let Some((m, copies, options, _)) = transform && options.objects { - transform_placed(art, m, copies); + // A mask's art that is one image or symbol instance is placed as the only member of a + // group of it. + let root = is_placed(art); + if root { + let id = art.id; + let one = std::mem::replace(art, Node::group(id, vec![])); + *art = Node::group(id, vec![Arc::new(one)]); + } + let placed = if watch { masked_placed(art, own) } else { 0 }; + transform_placed(art, m, copies, copy_scaling(options)); + short |= watch && masked_placed(art, own) < placed.saturating_mul(copies.saturating_add(1)); + if root && let Some([one]) = art.children().map(Vec::as_slice) { + *art = (**one).clone(); + } } if outlines.iter().any(Option::is_some) { rebase_type_outlines(art, &mut outlines.into_iter()); } + // Under Transform copies, a mask takes the copies as the paths it masks do; the copies of a + // path with a mask of its own each carry it ([`copy_leaf`]). + let masks = self.reshape_masks(art, own, &Skip { moved: &moved_ids, kept: &kept_ids }, max, used, depth); // What paints the art as a whole, and the own fills and strokes of the groups inside it, // follow their bounds under Free Distort and Warp (they stay when no path changed them). // Each stays when a path under it that its bounds measure kept its shape (past the budget). let mut after = vec![]; - let images = if groups.is_empty() { None } else { warped.when_cut(art) }; + let images = if groups.is_empty() { None } else { warped.as_deref().and_then(|w| w.when_cut(art)) }; let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, images, &mut after).1; if !groups.is_empty() { - carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed)); + carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, params, bounds, from, to, seed)); } - if follows && !held { - whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), warped.bounds(art), seed)); + let drawn = warped.as_deref().map_or_else(|| art.geometric_bounds(), |w| w.bounds(art)); + let whole = (follows && !held).then(|| crate::Carry::of(&e.id, params, bounds, Some(bounds), drawn, seed)); + Ran { kept: !kept_ids.is_empty(), masks, short, whole } + } + + /// Run the effect over the art of the linked opacity mask of `n` (with `own`) and of each path, + /// compound path, group and layer under it, with the art's reference box, as the paths the mask + /// covers take it: the mask moves and bends with what it masks. The mask of an object + /// transformed as an object moved with it already and is left as it is. A mask stays as it was + /// past [`vectorcraft_doc::depth::MAX_DEPTH`] masks inside masks, and for the reasons + /// [`Step::reshape_own_mask`] gives. Returns whether a mask stayed for one of those reasons. + fn reshape_masks(&self, n: &mut Node, own: bool, skip: &Skip, max: Budget, used: &mut Budget, depth: usize) -> bool { + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !has_linked_mask(n, own) { + return false; + } + let mut stayed = own && self.reshape_own_mask(n, skip, max, used, depth); + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind { + for c in children.iter_mut() { + if has_linked_mask(c, true) { + stayed |= self.reshape_masks(Arc::make_mut(c), true, skip, max, used, depth); + } + } + } + stayed + } + + /// Run the effect over the art of the linked opacity mask of `n` itself, when `n` is a path, + /// compound path, group or layer that `skip` does not list as moved. The mask stays as it was + /// when a path it masks kept its shape, and when its own art could not take the effect whole: a + /// path in it would keep its shape, its copies would stop short, or a mask inside it would stay + /// (no mask is left half moved). Returns whether the mask stayed for one of those reasons. + fn reshape_own_mask(&self, n: &mut Node, skip: &Skip, max: Budget, used: &mut Budget, depth: usize) -> bool { + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !reshapes_mask_of(n) || skip.moved.contains(&n.id) { + return false; + } + let stays = holds_any(n, skip.kept); + let id = n.id; + let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) else { return false }; + let before = *used; + let mut art = (*m.art).clone(); + let whole = self.wholes.get(&id).copied().unwrap_or_else(|| copies_whole(&art)); + let ran = (!stays).then(|| self.run(&mut art, max, used, whole, None, true, depth + 1)); + match ran { + Some(ran) if ran.complete() => { + // What paints the mask's art as a whole follows the effect as the art does. + if let Some(carry) = ran.whole + && carry.moves(&art.appearance) + { + carry.apply(&mut art.appearance); + } + m.art = Arc::new(art); + false + } + _ => { + *used = before; + true + } } } - cut_warped_images(art, &warped); - whole +} + +/// The objects whose linked masks [`Step::reshape_masks`] leaves as they are: those transformed +/// as objects (`moved`), and those holding a path that kept its shape (`kept`). +struct Skip<'a> { + moved: &'a HashSet, + kept: &'a HashSet, +} + +/// Is `n` an object whose linked opacity mask a geometry effect on the art it is in reshapes with +/// it: a path, compound path, group or layer? An image or symbol instance moves its mask only +/// with the map a Transform effect places it by ([`transform_placed`]). +fn reshapes_mask_of(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Layer { .. }) +} + +/// Does `n` (with `own`) or a path, compound path, group or layer under it carry a linked opacity +/// mask that [`Step::reshape_masks`] reshapes? +fn has_linked_mask(n: &Node, own: bool) -> bool { + let mine = own && reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked); + mine || match &n.kind { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().any(|c| has_linked_mask(c, true)), + _ => false, + } +} + +/// Run `f` on the id of `n` (with `own`) and of each path, compound path, group and layer under it +/// that carries a linked opacity mask, with the art of that mask, and on the masks inside that +/// art, as [`Step::reshape_masks`] reaches them: `depth` masks deep already, up to +/// [`vectorcraft_doc::depth::MAX_DEPTH`]. +fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(NodeId, &Node)) { + if depth >= vectorcraft_doc::depth::MAX_DEPTH { + return; + } + if own + && reshapes_mask_of(n) + && let Some(m) = n.extras().mask.as_deref().filter(|m| m.linked) + { + f(n.id, &m.art); + each_linked_mask(&m.art, true, depth + 1, f); + } + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind { + for c in children { + each_linked_mask(c, true, depth, f); + } + } +} + +/// [`each_linked_mask`] that changes the art of each mask: `f` runs on a mask's art before the +/// masks inside it are reached. +fn each_linked_mask_mut(n: &mut Node, own: bool, depth: usize, f: &mut dyn FnMut(&mut Node)) { + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !has_linked_mask(n, own) { + return; + } + if own + && reshapes_mask_of(n) + && let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) + { + let art = Arc::make_mut(&mut m.art); + f(art); + each_linked_mask_mut(art, true, depth + 1, f); + } + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind { + for c in children.iter_mut() { + if has_linked_mask(c, true) { + each_linked_mask_mut(Arc::make_mut(c), true, depth, f); + } + } + } +} + +/// Does [`Step::reshape_masks`] reshape the linked opacity mask of `n` itself? +fn has_own_linked_mask(n: &Node) -> bool { + reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked) +} + +/// Add to `out` the ids of the objects under `n` that the linked opacity mask of an object above +/// them covers (every one of them with `under`). +fn masked_ids(n: &Node, under: bool, out: &mut HashSet) { + for c in n.children().into_iter().flatten() { + if under { + out.insert(c.id); + } + masked_ids(c, under || has_own_linked_mask(c), out); + } +} + +/// How many images and symbol instances under `n` the linked opacity mask of an object above them +/// covers (every one of them with `under`). +fn masked_placed(n: &Node, under: bool) -> usize { + n.children().into_iter().flatten().map(|c| usize::from(under && is_placed(c)) + masked_placed(c, under || has_own_linked_mask(c))).sum() +} + +/// Is `n`, or anything under it, one of `ids`? +fn holds_any(n: &Node, ids: &HashSet) -> bool { + if ids.is_empty() { + return false; + } + let mut any = false; + n.walk(&mut |c| any |= ids.contains(&c.id)); + any +} + +/// Do the paths under `n` that [`for_each_leaf`] visits all fill by the non-zero rule without a +/// stroke that paints? Effects then reshape their outlines alike in any order that the +/// optimization of [`crate::geometry::optimize_geometry_effects`] takes. +fn leaves_safe(n: &Node) -> bool { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => { + let has_stroke = n.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); + leaf_geometry(n).is_none_or(|(_, rule)| rule == FillRule::NonZero && !has_stroke) + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().all(|c| leaves_safe(c)), + _ => true, + } +} + +/// Is there an image with a linked opacity mask among the members of `n`, or among its groups' +/// and layers' members, which [`mark_warped_images`] walks? +fn has_masked_image(n: &Node) -> bool { + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return false }; + children.iter().any(|c| match c.kind { + NodeKind::Image(_) => c.extras().mask.as_ref().is_some_and(|m| m.linked), + _ => has_masked_image(c), + }) } /// Largest factor a Transform effect's Scale Strokes & Effects scales one object's strokes by @@ -1082,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; } @@ -1622,17 +1981,20 @@ fn box_map(from: Rect, to: Rect) -> vectorcraft_geom::Affine { /// Apply the Transform effect's map `m` to every image and symbol instance under `n`, as /// [`for_each_leaf`] does to paths: the map goes into the object's placement, and `copies` more /// follow, each mapped once more than the last. Without copies the object itself takes the map, -/// as a path does. -fn transform_placed(n: &mut Node, m: Affine, copies: usize) { - transform_placed_within(n, m, copies, crate::distort::MAX_COPY_OBJECTS) +/// as a path does. The art of each one's linked opacity mask takes the map with `scaling`, as a +/// path's mask does. +fn transform_placed(n: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling) { + let mut room = crate::distort::MAX_COPY_OBJECTS.saturating_sub(placed_count(n)); + copy_placed(n, m, copies, scaling, &mut room); } /// [`transform_placed`] whose copies make at most `max` image and symbol instance nodes of the art, /// the originals included: each copy adds the object once more, and a Transform after this one /// copies the lot again, so copies stop where the result would pass `max` (#2042). +#[cfg(test)] pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, max: usize) { let mut room = max.saturating_sub(placed_count(n)); - copy_placed(n, m, copies, &mut room); + copy_placed(n, m, copies, vectorcraft_doc::Scaling::default(), &mut room); } /// Rows and columns of the pieces a Warp effect cuts an image into (VectorCraft's own number). @@ -1864,11 +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(); @@ -1887,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/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 { + let offsets: Vec = g.expanded_stops().iter().map(|s| f64::from(s.0)).collect(); + let shared = |i: usize| { + let t = offsets.get(i).copied().unwrap_or(0.0); + let near = |j: Option| j.and_then(|j| offsets.get(j)).is_some_and(|&u| (u - t).abs() < 1e-6); + near(i.checked_sub(1)) || near(i.checked_add(1)) + }; + let mut out = Vec::with_capacity(offsets.len() + 2); + for (i, &t) in offsets.iter().enumerate() { + out.push(t); + if shared(i) { + out.extend([(t - STEP_GAP).max(0.0), (t + STEP_GAP).min(1.0)]); + } + } + out +} + /// Outline Stroke of stroke `st` of `bp` (filled with `rule`) whose gradient `g` runs along or /// across it: a gradient mesh per subpath over all the stroke can paint there (clip it to the /// stroke's outline), coloured as [`gradient_slices`] paint it. pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec { + 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 = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() }; + let marks: Vec = 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 = if across { - (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(g.expanded_stops().iter().map(|s| s.0 as f64)).collect() - } else { - vec![0.5] - }; + let mut us: Vec = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(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/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 { - stencil::specs() + let mut v = stencil::specs(); + v.extend(weed::specs()); + v } diff --git a/crates/engine/src/cmd/addons/cutting/stencil.rs b/crates/engine/src/cmd/addons/cutting/stencil.rs index a8b4cfe591..dc415ce73d 100644 --- a/crates/engine/src/cmd/addons/cutting/stencil.rs +++ b/crates/engine/src/cmd/addons/cutting/stencil.rs @@ -89,7 +89,12 @@ fn bridges(s: &mut Session, p: &Value) -> Result { }; let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect(); let spec = BridgeSpec { width, count: count as usize, direction }; - let bridged = stencil_bridges(&refs, spec).map_err(|e| EngineError::Other(e.to_string()))?; + let bridged = stencil_bridges(&refs, spec).map_err(|e| match e { + vectorcraft_pathops::PathOpsError::TooComplex => { + EngineError::Other("Stencil Bridges: the selected art is too large or too detailed to bridge".into()) + } + e => EngineError::Other(e.to_string()), + })?; (bridged, style_of(&top), roots) }; let summary = |id: Option, original: Option| { diff --git a/crates/engine/src/cmd/addons/cutting/stencil/tests.rs b/crates/engine/src/cmd/addons/cutting/stencil/tests.rs index ad072ca13d..b21f0fef5c 100644 --- a/crates/engine/src/cmd/addons/cutting/stencil/tests.rs +++ b/crates/engine/src/cmd/addons/cutting/stencil/tests.rs @@ -277,6 +277,34 @@ fn bad_params_are_refused() { assert!(s.execute("addon.cutting.bridges", &json!({"width": 4})).is_ok(), "good params run"); } +#[test] +fn art_too_detailed_to_bridge_is_refused_with_a_message_that_says_so() { + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("dense", |d, sel| { + // More anchors than Stencil Bridges measures. + let mut zigzag = kurbo::BezPath::new(); + zigzag.move_to((100.0, 100.0)); + for i in 1..=vectorcraft_pathops::addons::stencil::MAX_STENCIL_SEGMENTS { + zigzag.line_to((100.0 + i as f64 * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + zigzag.close_path(); + let huge = vectorcraft_geom::PathData::from_bezpath(&zigzag); + let n = vectorcraft_doc::Node::path(d.alloc_id(), huge, vectorcraft_doc::Appearance::default()); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let steps = undo_steps(&s); + let e = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap_err().to_string(); + assert!(e.contains("Stencil Bridges: the selected art is too large or too detailed to bridge"), "{e}"); + assert_eq!(undo_steps(&s), steps); +} + #[test] fn selected_objects_without_outlines_stay_where_they_are() { for keep in [true, false] { diff --git a/crates/engine/src/cmd/addons/cutting/weed.rs b/crates/engine/src/cmd/addons/cutting/weed.rs new file mode 100644 index 0000000000..a05770a024 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/weed.rs @@ -0,0 +1,160 @@ +//! Weed Box and Lines (`addon.cutting.weed`), a command of the built-in Cutting Tools add-on for +//! vinyl cutting: around the selected art it adds a weed box (a rectangle at a margin around the +//! art) and weeding lines across it that stop at the art, so the waste vinyl around the art lifts +//! off in pieces (). The art is measured by the +//! outlines a cutter cuts ([`geometry`]). + +mod geometry; +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Rect, shapes}; + +use super::super::super::edit::selected_roots; +use super::super::super::object::choice; +use super::super::super::paint::swatch_solid; +use super::super::super::pathops::{length_given, number_given}; +use super::super::super::{CommandSpec, bad, cmd, has_selection, switch_or, text_param}; +use crate::{EngineError, Result, Session}; + +/// Weed Box and Lines' command. +pub const WEED: &str = "addon.cutting.weed"; +/// Which way the weeding lines run. +pub const DIRECTIONS: [&str; 3] = ["horizontal", "vertical", "both"]; +/// The largest margin, gap or spacing, in points. +const MAX_DISTANCE: f64 = 100_000.0; +/// The most weeding lines each way. +const MAX_COUNT: usize = 1000; +/// The most pieces of weeding line one run adds. +const MAX_PIECES: usize = 100_000; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.cutting.weed", + "Weed Box and Lines…", + ["Object", "Plug-ins", "Cutting Tools"], + None, + "{margin?: pt 0..100000 (9: from the art to the weed box), box?: true (false: the weeding lines only), direction?: \"horizontal\" | \"vertical\" | \"both\" (\"both\"), count?: whole number 0..1000 of lines each way (1), spacing?: pt (instead of count: as few lines as keep them at most this far apart), gap?: pt 0..100000 (0: the lines stop at the art's outlines), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the lines are stroked in (\"[Registration]\")} add a weed box around the selected art and weeding lines across it that divide it evenly, each running from one side of the box to the other and left out over the art and within `gap` of its outlines, so the waste lifts off in pieces. The art is measured by the outlines a cutter cuts: closed paths enclose their area by their fill rule and open paths are lines, compound paths by all their subpaths, type by its glyph outlines, a clip group by what shows inside its clipping path, images by their frames, placed files by the art they show (by their boxes when the file can't be read), symbol instances by their art and live objects by the art they draw; strokes, effects and hidden objects are left out. One group named Weeding, stroked with no fill, on top of the current layer (outside the clip groups around a group edited in isolation), selected, as one undo step → {id: the group, box: its weed box or null, lines (pieces of weeding line)}", + has_selection, + weed + )] +} + +/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`. +fn distance(p: &Value, key: &str, default: f64) -> Result { + match length_given(p, key, WEED)? { + None => Ok(default), + Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v), + Some(_) => Err(bad(WEED, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))), + } +} + +/// How many lines divide a side `length` long: `count`, or as few as keep them at most `spacing` +/// apart. +fn lines_across(length: f64, count: Option, spacing: Option) -> Result { + let n = match spacing { + // `length / spacing` strips, rounded up, need one line fewer. + Some(s) => ((length / s - 1e-9).ceil() - 1.0).max(0.0), + None => return Ok(count.unwrap_or(1)), + }; + if n > MAX_COUNT as f64 { + return Err(bad(WEED, format!("`spacing` makes more than {MAX_COUNT} lines across the box"))); + } + Ok(n as usize) +} + +/// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping +/// sublayer (a group edited in isolation), the container around the outermost of them. A clipping +/// path would hide the lines and the box, which lie outside the art. +fn outside_clips(d: &Document, parent: Option) -> Option { + let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })); + let (mut at, mut out) = (parent, parent); + while let Some(p) = at { + let up = d.parent_of(p); + if clips(p) { + match up { + Some(up) if d.is_editable(up) => out = Some(up), + _ => break, + } + } + at = up; + } + out +} + +fn weed(s: &mut Session, p: &Value) -> Result { + let margin = distance(p, "margin", 9.0)?; + let gap = distance(p, "gap", 0.0)?; + let with_box = switch_or(p, "box", true, WEED)?; + let direction = choice(p, WEED, "direction", &DIRECTIONS)?.unwrap_or("both"); + let count = match number_given(p, "count", WEED)? { + None => None, + Some(n) if n.fract() == 0.0 && (0.0..=MAX_COUNT as f64).contains(&n) => Some(n as usize), + Some(_) => return Err(bad(WEED, format!("`count` must be a whole number from 0 to {MAX_COUNT}"))), + }; + let spacing = match length_given(p, "spacing", WEED)? { + None => None, + Some(v) if v > 0.0 && v <= MAX_DISTANCE => Some(v), + Some(_) => return Err(bad(WEED, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))), + }; + if count.is_some() && spacing.is_some() { + return Err(bad(WEED, "give `count` or `spacing`, not both")); + } + let weight = match length_given(p, "weight", WEED)? { + None => 0.3, + Some(v) if (0.01..=1000.0).contains(&v) => v, + Some(_) => return Err(bad(WEED, "`weight` must be from 0.01 to 1000 pt")), + }; + let swatch = text_param(p, "swatch", WEED)?.unwrap_or(REGISTRATION); + let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(WEED, e))?; + let roots = selected_roots(s)?; + if roots.is_empty() { + return Err(bad(WEED, "select the art to weed")); + } + let d = &s.doc()?.doc; + let parent = outside_clips(d, s.doc()?.target_parent()?); + // Placed files' art uses their resources (symbols) under names of their own. + let placed = d.with_placed_art(); + let region = geometry::Reader::new(&placed).read_ids(&roots).map_err(|e| bad(WEED, e))?; + let region = region.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?; + let outline = region.outline().map_err(|e| bad(WEED, e))?; + let art = outline.iter().fold(None, |r: Option, &(a, b)| Some(r.map_or(Rect::from_points(a, b), |r| r.union_pt(a).union_pt(b)))); + let frame = art.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?.inflate(margin, margin); + if !(frame.width().is_finite() && frame.height().is_finite()) { + return Err(bad(WEED, "the selected art is too large to measure")); + } + let each = |on: bool, length: f64| if on { lines_across(length, count, spacing) } else { Ok(0) }; + let counts = + geometry::Counts { horizontal: each(direction != "vertical", frame.height())?, vertical: each(direction != "horizontal", frame.width())? }; + let pieces = geometry::lines(®ion, &outline, frame, counts, gap, MAX_PIECES).map_err(|e| bad(WEED, e))?; + if pieces.is_empty() && !with_box { + return Err(EngineError::Other("Weed Box and Lines: no weeding line fits outside the art, and the weed box is off".into())); + } + let ap = Appearance::basic(Paint::None, stroke, weight); + let (group, weed_box, lines) = s.edit("Weed Box and Lines", |d, sel| { + let mut children = vec![]; + let mut weed_box = None; + if with_box { + let mut b = Node::path(d.alloc_id(), shapes::rectangle(frame), ap.clone()); + b.name = Some("Weed Box".into()); + weed_box = Some(b.id); + children.push(Arc::new(b)); + } + for (a, b) in &pieces { + children.push(Arc::new(Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone()))); + } + let mut g = Node::group(d.alloc_id(), children); + g.name = Some("Weeding".into()); + let id = g.id; + d.insert(parent, usize::MAX, g)?; + sel.set([id]); + Ok((id, weed_box, pieces.len())) + })?; + Ok(json!({ "id": group.0, "box": weed_box.map(|b| b.0), "lines": lines })) +} diff --git a/crates/engine/src/cmd/addons/cutting/weed/geometry.rs b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs new file mode 100644 index 0000000000..4642031e71 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs @@ -0,0 +1,769 @@ +//! The geometry of Weed Box and Lines: the selected art as the outlines a cutter cuts ([`Region`]), +//! and the weeding lines across a box that stop at those outlines or at a gap from them +//! ([`lines`]). +//! +//! A region is measured along one straight line at a time. A closed outline encloses its area by +//! its fill rule, an open one is a line with no area, a group is the union of its members and a +//! clip group is what its members draw inside its clipping path. Along a weeding line the region +//! gives the stretches inside its area ([`Region::spans`]), and its outline pieces +//! ([`Region::outline`]) give the stretches within the gap of an outline. + +use std::cell::Cell; + +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{BezPath, FillRule, PathEl, Point, Rect}; + +use super::super::super::super::pathops::node_path; + +/// How closely curves are followed, in points. +const TOLERANCE: f64 = 0.02; +/// The most points the outlines of one selection are measured by, once curves are followed in +/// straight segments (each subpath's points, its first one included). +pub(super) const MAX_POINTS: usize = 200_000; +/// The most tests of a segment against another one measuring a clip group's outlines takes. +const MAX_PAIRS: usize = 40_000_000; +/// The deepest groups, symbol instances and live objects are opened inside one another: as deep +/// as any object is drawn. +pub(super) const MAX_DEPTH: usize = vectorcraft_doc::depth::MAX_DRAWN_DEPTH; +/// The most pairs of an outline segment and a weeding line it comes near that one run measures. +const MAX_NEAR: usize = 10_000_000; +/// Pieces of weeding line shorter than this, in points, are left out. +const MIN_PIECE: f64 = 0.01; + +/// A straight segment. +pub(super) type Segment = (Point, Point); + +/// One subpath followed in straight segments. +#[derive(Clone, Debug)] +pub(super) struct Poly { + points: Vec, + closed: bool, + /// The box around `points`. + bounds: Rect, +} + +impl Poly { + /// The segments, with the closing one of a closed subpath. + fn segments(&self) -> impl Iterator + '_ { + let closing = self.closed.then(|| (self.points.last().copied(), self.points.first().copied())); + self.points.windows(2).map(|w| (w[0], w[1])).chain(closing.and_then(|(a, b)| Some((a?, b?)))) + } +} + +/// The selected art as the outlines a cutter cuts. +#[derive(Clone, Debug)] +pub(super) enum Region { + /// A path, compound path or type: its subpaths with the rule its closed ones fill by. + Shape { polys: Vec, rule: FillRule }, + /// Everything its members draw. + Union(Vec), + /// What `content` draws inside `clip`. + Clip { clip: Box, content: Box }, +} + +/// Reads the selected objects into a [`Region`], keeping count of the outline points. +pub(super) struct Reader<'a> { + doc: &'a Document, + points: usize, +} + +impl<'a> Reader<'a> { + pub(super) fn new(doc: &'a Document) -> Self { + Self { doc, points: 0 } + } + + /// The region of `nodes` together, `None` when they hold no outlines. + pub(super) fn read(&mut self, nodes: &[&Node]) -> Result, String> { + let mut parts = vec![]; + for n in nodes { + parts.extend(self.node(n, 0)?); + } + Ok(union(parts)) + } + + fn node(&mut self, n: &Node, depth: usize) -> Result, String> { + if depth > MAX_DEPTH { + return Err(format!("the selected art is nested more than {MAX_DEPTH} levels deep")); + } + if !n.visible { + return Ok(None); + } + match &n.kind { + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => Ok(None), + NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => self.shape(n), + NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } => { + let members = n.editing_children(); + if *clip { + let Some((first, rest)) = members.split_first() else { return Ok(None) }; + let Some(clip) = self.clip_region(first)? else { return Ok(None) }; + let mut parts = vec![]; + for c in rest { + parts.extend(self.node(c, depth + 1)?); + } + Ok(union(parts).map(|content| Region::Clip { clip: Box::new(clip), content: Box::new(content) })) + } else { + let mut parts = vec![]; + for c in members { + parts.extend(self.node(c, depth + 1)?); + } + Ok(union(parts)) + } + } + NodeKind::Image(im) => { + let (w, h) = (f64::from(im.width), f64::from(im.height)); + let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, h), Point::new(0.0, h)].map(|p| im.xf * p); + self.polygon(&corners) + } + // An instance of a symbol the document doesn't have draws nothing. + NodeKind::SymbolInstance { .. } => match vectorcraft_render::effects::outline_object(Some(self.doc), n) { + Some(art) => self.node(&art, depth + 1), + None => Ok(None), + }, + NodeKind::PlacedDocument(p) => { + // The art of the file it shows; a file that can't be read shows its preview in its + // box, which may be rotated. + if vectorcraft_doc::placed_document::read_cached(p).is_none() { + return self.polygon(&p.corners()); + } + let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n); + if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) { + self.polygon(&p.corners()) + } else { + self.node(&art, depth + 1) + } + } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { + // The art a live object draws, as the exporters write it; an object that stays + // as it is is measured by its box. + let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n); + if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) { self.frame(n) } else { self.node(&art, depth + 1) } + } + } + } + + /// The region a clipping path `first` clips to, whatever its own visibility: the region the + /// renderer clips to (a path, a compound path, type by its glyphs, a group's visible members, + /// an image's frame). `None` when it clips to nothing, and the clipped art is hidden. + fn clip_region(&mut self, first: &Node) -> Result, String> { + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(first) else { return Ok(None) }; + let mut polys = self.polys(outline)?; + // A region is filled: an open subpath clips as if closed. + polys.iter_mut().for_each(|p| p.closed = true); + Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule })) + } + + /// The region of the objects `ids` together, each within the clipping paths of the clip groups + /// and layers around it (an object selected inside a clip group shows only within its clipping + /// path, and not at all inside a hidden group or layer; a clipping path is measured as its clip + /// group), `None` when they show no outlines. + pub(super) fn read_ids(&mut self, ids: &[NodeId]) -> Result, String> { + let doc = self.doc; + let clips = |n: &Node| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); + // Every object's place in the tree, found in one walk. + let places = doc.index_paths(ids.iter().copied()); + let mut paths = vec![]; + for id in ids { + let Some(mut path) = places.get(id).cloned() else { continue }; + // A clipping path selected on its own stands for the art it shows: its clip group. + while let (Some(n), Some(up)) = (doc.node_at(&path), path.split_last().map(|(_, up)| up).and_then(|up| doc.node_at(up))) { + if !(clips(up) && up.editing_children().first().is_some_and(|f| f.id == n.id)) { + break; + } + path.pop(); + } + paths.push(path); + } + // An object selected twice, or inside another selected object, is measured once, with it. + paths.sort(); + paths.dedup(); + let mut kept: Vec> = vec![]; + for path in paths { + if !kept.last().is_some_and(|k| path.starts_with(k)) { + kept.push(path); + } + } + let mut parts = vec![]; + 'ids: for path in &kept { + let Some(n) = doc.node_at(path) else { continue }; + // Art in a hidden group or layer isn't shown, and isn't measured. + if (1..path.len()).any(|depth| path.get(..depth).and_then(|p| doc.node_at(p)).is_some_and(|c| !c.visible)) { + continue; + } + let Some(mut region) = self.read(&[n])? else { continue }; + // The containers around the object, innermost first. + let mut child = n.id; + for depth in (1..path.len()).rev() { + let Some(container) = path.get(..depth).and_then(|p| doc.node_at(p)) else { break }; + if clips(container) + && let Some(first) = container.editing_children().first().filter(|f| f.id != child) + { + let Some(clip) = self.clip_region(first)? else { continue 'ids }; + region = Region::Clip { clip: Box::new(clip), content: Box::new(region) }; + } + child = container.id; + } + parts.push(region); + } + Ok(union(parts)) + } + + /// A path, compound path or type as its outlines. + fn shape(&mut self, n: &Node) -> Result, String> { + // Each anchor is at least one point, so a path past the budget errs before it is copied. + if anchors(n) > MAX_POINTS.saturating_sub(self.points) { + return Err(too_many()); + } + let Some((path, rule)) = node_path(n) else { return Ok(None) }; + let polys = self.polys(path.to_bezpath())?; + Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule })) + } + + /// The box of an object whose outlines can't be read. + fn frame(&mut self, n: &Node) -> Result, String> { + match n.geometric_bounds_with(&self.doc.symbol_boxes()) { + Some(r) => self.polygon(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]), + None => Ok(None), + } + } + + /// A closed polygon through `points`. + fn polygon(&mut self, points: &[Point]) -> Result, String> { + let poly = poly(points.to_vec(), true); + self.count(poly.iter().map(|p| p.points.len()).sum())?; + Ok(poly.map(|p| Region::Shape { polys: vec![p], rule: FillRule::NonZero })) + } + + fn count(&mut self, n: usize) -> Result<(), String> { + self.points = self.points.saturating_add(n); + if self.points > MAX_POINTS { Err(too_many()) } else { Ok(()) } + } + + /// The subpaths of `path` followed in straight segments. + fn polys(&mut self, path: BezPath) -> Result, String> { + let (mut out, mut current, points) = (vec![], vec![], Cell::new(0usize)); + let left = MAX_POINTS.saturating_sub(self.points); + // Past the budget no further curve is followed (one curve gives at most + // `MAX_FLATTEN_PER_CURVE` points), and the read fails below. + let elements = path.into_iter().take_while(|_| points.get() <= left); + vectorcraft_geom::flatten(elements, TOLERANCE, |el| { + points.set(points.get() + 1); + if points.get() > left { + return; + } + match el { + PathEl::MoveTo(p) => { + out.extend(poly(std::mem::take(&mut current), false)); + current.push(p); + } + PathEl::LineTo(p) => current.push(p), + PathEl::ClosePath => out.extend(poly(std::mem::take(&mut current), true)), + PathEl::QuadTo(..) | PathEl::CurveTo(..) => {} + } + }); + if points.get() > left { + return Err(too_many()); + } + out.extend(poly(current, false)); + self.count(out.iter().map(|p: &Poly| p.points.len()).sum())?; + Ok(out) + } +} + +fn too_many() -> String { + format!("the selected art has too many outline points to measure (more than {MAX_POINTS})") +} + +/// The anchors of a path or compound path node, read without copying its path; 0 for type. +fn anchors(n: &Node) -> usize { + match &n.kind { + NodeKind::Path { path, .. } => path.anchor_count(), + NodeKind::Compound { children, .. } => children.iter().fold(0, |sum, c| sum.saturating_add(anchors(c))), + _ => 0, + } +} + +/// A subpath through `points`, `None` for fewer than two points or one that isn't finite. +fn poly(points: Vec, closed: bool) -> Option { + if points.len() < 2 || points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return None; + } + let first = *points.first()?; + let bounds = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)); + Some(Poly { points, closed, bounds }) +} + +/// One region of `parts`, `None` when there are none. +fn union(mut parts: Vec) -> Option { + match parts.len() { + 0 => None, + 1 => parts.pop(), + _ => Some(Region::Union(parts)), + } +} + +/// `p` as (along, across) a weeding line: horizontal lines run along x, vertical ones along y. +fn uv(p: Point, vertical: bool) -> (f64, f64) { + if vertical { (p.y, p.x) } else { (p.x, p.y) } +} + +/// Sorts `spans` and joins those that overlap or touch. +fn merged(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> { + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec<(f64, f64)> = Vec::with_capacity(spans.len()); + for (a, b) in spans { + match out.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + out +} + +/// Where sorted, joined spans `a` and `b` overlap. +fn intersected(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { + let (mut i, mut j, mut out) = (0, 0, vec![]); + while let (Some(&(a0, a1)), Some(&(b0, b1))) = (a.get(i), b.get(j)) { + let (lo, hi) = (a0.max(b0), a1.min(b1)); + if lo <= hi { + out.push((lo, hi)); + } + if a1 < b1 { + i += 1; + } else { + j += 1; + } + } + out +} + +/// Where segment a→b crosses the line `across` = `c`, along it, and which way (1 when `across` +/// grows from a to b), `None` when it doesn't. Each crossing counts once: an end exactly on the +/// line counts as past it. +fn crosses(a: Point, b: Point, vertical: bool, c: f64) -> Option<(f64, i32)> { + let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical)); + ((v0 <= c && v1 > c) || (v1 <= c && v0 > c)).then(|| (u0 + (c - v0) * (u1 - u0) / (v1 - v0), if v1 > v0 { 1 } else { -1 })) +} + +/// The stretches inside an area filled by `rule`, from where its outlines cross a line, sorted +/// along it. +fn filled(crossings: impl IntoIterator, rule: FillRule) -> Vec<(f64, f64)> { + let (mut winding, mut start, mut out) = (0, 0.0, vec![]); + let inside = |w: i32| match rule { + FillRule::NonZero => w != 0, + FillRule::EvenOdd => w % 2 != 0, + }; + for (u, dir) in crossings { + let was = inside(winding); + winding += dir; + match (was, inside(winding)) { + (false, true) => start = u, + (true, false) => out.push((start, u)), + _ => {} + } + } + merged(out) +} + +/// Where the closed outlines of a region's shapes cross a set of weeding lines: for each line, the +/// crossings as (shape, place along the line, direction), sorted by shape and then by place. Shapes +/// are numbered in the order [`Region::shapes`] lists them. +pub(super) type Crossings = Vec>; + +/// Where the shapes of each part of a region are numbered among its [`Crossings`]. +#[derive(Debug)] +pub(super) enum Layout { + Shape, + /// The number of each part's first shape, from the union's first, and the parts' layouts. + Union { + starts: Vec, + parts: Vec, + }, + /// The clipping path's shapes come first. + Clip { + clip_shapes: usize, + clip: Box, + content: Box, + }, +} + +impl Layout { + /// The layout of `region` and how many shapes it has. + pub(super) fn of(region: &Region) -> (Layout, usize) { + match region { + Region::Shape { .. } => (Layout::Shape, 1), + Region::Union(parts) => { + let (mut starts, mut layouts, mut n) = (vec![], vec![], 0); + for r in parts { + let (l, k) = Layout::of(r); + starts.push(n); + layouts.push(l); + n += k; + } + (Layout::Union { starts, parts: layouts }, n) + } + Region::Clip { clip, content } => { + let ((lc, a), (lt, b)) = (Layout::of(clip), Layout::of(content)); + (Layout::Clip { clip_shapes: a, clip: Box::new(lc), content: Box::new(lt) }, a + b) + } + } + } +} + +/// The [`Crossings`] of `region` with the lines `across` = v0 + (k + 1)·step, k < `count`. Errs +/// past [`MAX_NEAR`] crossings in all. +pub(super) fn crossings(region: &Region, vertical: bool, v0: f64, step: f64, count: usize) -> Result { + let mut shapes = vec![]; + region.shapes(&mut shapes); + let at = |k: usize| v0 + step * (k as f64 + 1.0); + // The lines a segment may cross: those from just below its lower end to its higher end. + let candidates = |(a, b): Segment| { + let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1); + if step <= 0.0 || va == vb { + return 0..0; + } + let first = (((va.min(vb) - v0) / step).floor() - 1.0).clamp(0.0, count as f64); + let end = ((va.max(vb) - v0) / step).ceil().clamp(0.0, count as f64); + (first as usize)..(end as usize) + }; + let closed = |polys: &[Poly]| polys.iter().filter(|p| p.closed).flat_map(Poly::segments).collect::>(); + let total = shapes.iter().flat_map(|(polys, _)| closed(polys)).fold(0usize, |n, s| n.saturating_add(candidates(s).len())); + if total > MAX_NEAR { + return Err("the weeding lines cross the art's outlines too many times to measure; use fewer lines".into()); + } + let mut out: Crossings = vec![vec![]; count]; + for (i, (polys, _)) in shapes.iter().enumerate() { + for (a, b) in closed(polys) { + for k in candidates((a, b)) { + if let (Some((u, dir)), Some(line)) = (crosses(a, b, vertical, at(k)), out.get_mut(k)) { + line.push((i, u, dir)); + } + } + } + } + for line in &mut out { + line.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))); + } + Ok(out) +} + +impl Region { + /// The stretches of the line `across` = `c` (horizontal: y = c, along x; vertical: x = c, + /// along y) inside the region's area, sorted and joined. + pub(super) fn spans(&self, vertical: bool, c: f64) -> Vec<(f64, f64)> { + match self { + Region::Shape { polys, rule } => { + let mut crossings: Vec<(f64, i32)> = vec![]; + for p in polys.iter().filter(|p| p.closed) { + let (lo, hi) = if vertical { (p.bounds.x0, p.bounds.x1) } else { (p.bounds.y0, p.bounds.y1) }; + if c >= lo && c <= hi { + crossings.extend(p.segments().filter_map(|(a, b)| crosses(a, b, vertical, c))); + } + } + crossings.sort_by(|a, b| a.0.total_cmp(&b.0)); + filled(crossings, *rule) + } + Region::Union(parts) => merged(parts.iter().flat_map(|r| r.spans(vertical, c)).collect()), + Region::Clip { clip, content } => intersected(&clip.spans(vertical, c), &content.spans(vertical, c)), + } + } + + /// The region's shapes with their fill rules, in a fixed order. + fn shapes<'a>(&'a self, out: &mut Vec<(&'a [Poly], FillRule)>) { + match self { + Region::Shape { polys, rule } => out.push((polys, *rule)), + Region::Union(parts) => parts.iter().for_each(|r| r.shapes(out)), + Region::Clip { clip, content } => { + clip.shapes(out); + content.shapes(out); + } + } + } + + /// [`Region::spans`] on a weeding line, from `line`, the line's [`Crossings`] with the + /// region's shapes, numbered from `base` (`layout` is the region's [`Layout`]). Only the parts + /// the line crosses are visited: a part it doesn't cross has no stretch inside its area. + pub(super) fn spans_on(&self, layout: &Layout, line: &[(usize, f64, i32)], base: usize) -> Vec<(f64, f64)> { + if line.is_empty() { + return vec![]; + } + match (self, layout) { + (Region::Shape { rule, .. }, Layout::Shape) => filled(line.iter().map(|&(_, u, dir)| (u, dir)), *rule), + (Region::Union(parts), Layout::Union { starts, parts: layouts }) => { + let (mut out, mut rest) = (vec![], line); + while let Some(&(id, ..)) = rest.first() { + // The part whose shapes the next crossing is on, and its crossings. + let j = starts.partition_point(|&s| base + s <= id).saturating_sub(1); + let (Some(r), Some(l), Some(&start)) = (parts.get(j), layouts.get(j), starts.get(j)) else { break }; + let end = starts.get(j + 1).map_or(usize::MAX, |&s| base + s); + let n = rest.partition_point(|c| c.0 < end); + if n == 0 { + break; + } + let (mine, after) = rest.split_at(n); + out.extend(r.spans_on(l, mine, base + start)); + rest = after; + } + merged(out) + } + (Region::Clip { clip, content }, Layout::Clip { clip_shapes, clip: lc, content: lt }) => { + let (a, b) = line.split_at(line.partition_point(|c| c.0 < base + clip_shapes)); + if a.is_empty() || b.is_empty() { + return vec![]; + } + intersected(&clip.spans_on(lc, a, base), &content.spans_on(lt, b, base + clip_shapes)) + } + _ => vec![], + } + } + + /// Is `p` inside the region's area or on one of `segments`, the outlines it shows? + fn contains(&self, p: Point, segments: &[Segment]) -> bool { + const EPS: f64 = 1e-7; + self.spans(false, p.y).iter().any(|&(a, b)| p.x >= a - EPS && p.x <= b + EPS) || segments.iter().any(|&(a, b)| distance(p, a, b) <= EPS) + } + + /// The pieces of outline the region shows: every outline of a shape and of a group's members, + /// and in a clip group the members' outlines inside the clipping path and the clipping path's + /// outline where the members' area or outlines are. Errs past [`MAX_PAIRS`], counted over + /// every clip group in the region together. + pub(super) fn outline(&self) -> Result, String> { + self.outline_counted(&mut 0) + } + + /// [`Region::outline`], adding the measure's work to `work`. + fn outline_counted(&self, work: &mut usize) -> Result, String> { + match self { + Region::Shape { polys, .. } => Ok(polys.iter().flat_map(Poly::segments).collect()), + Region::Union(parts) => { + let mut out = vec![]; + for r in parts { + out.extend(r.outline_counted(work)?); + } + Ok(out) + } + Region::Clip { clip, content } => { + // Each side's area is bounded by the outlines it shows, so the pieces are cut and + // tested against those, never against art a nested clip hides. + let (clip_shown, content_shown) = (clip.outline_counted(work)?, content.outline_counted(work)?); + let mut out = within(&content_shown, clip, &clip_shown, work)?; + out.extend(within(&clip_shown, content, &content_shown, work)?); + Ok(out) + } + } + } + + /// The points of the region's closed outlines, those a nested clip hides included: what one + /// [`Region::spans`] looks at, at most. + fn closed_points(&self) -> usize { + match self { + Region::Shape { polys, .. } => polys.iter().filter(|p| p.closed).map(|p| p.points.len()).sum(), + Region::Union(parts) => parts.iter().map(Region::closed_points).sum(), + Region::Clip { clip, content } => clip.closed_points().saturating_add(content.closed_points()), + } + } +} + +fn too_complex_clip() -> String { + "a clip group in the selection has too many outline segments to measure".into() +} + +/// The pieces of `segments` inside `region` (or on its outlines), cut where they cross +/// `region_segments`, the outlines the region shows. Each segment, and each piece of one tested +/// for being inside, adds the region's closed outline points (hidden ones included) and its +/// shown segment count to `work`, and the measure errs past [`MAX_PAIRS`]. +fn within(segments: &[Segment], region: &Region, region_segments: &[Segment], work: &mut usize) -> Result, String> { + let mut out = vec![]; + let cost = region.closed_points().saturating_add(region_segments.len()).max(1); + let charge = |work: &mut usize| { + *work = work.saturating_add(cost); + if *work > MAX_PAIRS { Err(too_complex_clip()) } else { Ok(()) } + }; + for &(a, b) in segments { + charge(work)?; + let mut cuts = vec![0.0, 1.0]; + let seg_box = Rect::from_points(a, b); + for &(c, d) in region_segments { + if touches(seg_box, Rect::from_points(c, d)) { + cuts.extend(crossing(a, b, c, d)); + } + } + cuts.sort_by(f64::total_cmp); + cuts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); + let at = |t: f64| a + (b - a) * t; + for w in cuts.windows(2) { + let (t0, t1) = (w[0], w[1]); + if t1 - t0 < 1e-12 { + continue; + } + charge(work)?; + if region.contains(at((t0 + t1) / 2.0), region_segments) { + out.push((at(t0), at(t1))); + } + } + } + Ok(out) +} + +/// Do boxes `a` and `b` overlap or touch? +fn touches(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Where along a→b (0 to 1) segment c→d crosses or touches it: one place, or the ends of the +/// shared stretch when the two lie on one line. +fn crossing(a: Point, b: Point, c: Point, d: Point) -> Vec { + let (r, s) = (b - a, d - c); + let denom = r.cross(s); + let qp = c - a; + let len2 = r.hypot2(); + if len2 == 0.0 { + return vec![]; + } + if denom.abs() <= 1e-12 * len2.sqrt() * s.hypot().max(1e-300) { + // Parallel: the ends of c→d on a→b when the two lie on one line. + if qp.cross(r).abs() > 1e-9 * len2.sqrt() { + return vec![]; + } + return [c, d].iter().map(|p| (*p - a).dot(r) / len2).filter(|t| (0.0..=1.0).contains(t)).collect(); + } + let t = qp.cross(s) / denom; + let u = qp.cross(r) / denom; + if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) { vec![t] } else { vec![] } +} + +/// The distance from `p` to segment a→b. +fn distance(p: Point, a: Point, b: Point) -> f64 { + let ab = b - a; + let len2 = ab.hypot2(); + let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 }; + (p - (a + ab * t)).hypot() +} + +/// The stretch of the line `across` = `c` within `gap` of segment a→b, `None` when the line +/// passes farther away. With no gap it is where the segment meets the line. +fn near(a: Point, b: Point, vertical: bool, c: f64, gap: f64) -> Option<(f64, f64)> { + let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical)); + if gap <= 0.0 { + if v0 == v1 { + return (v0 == c).then_some((u0.min(u1), u0.max(u1))); + } + if c < v0.min(v1) || c > v0.max(v1) { + return None; + } + let u = u0 + (c - v0) * (u1 - u0) / (v1 - v0); + return Some((u, u)); + } + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + let mut take = |x: f64, y: f64| { + lo = lo.min(x); + hi = hi.max(y); + }; + // The round ends. + for (u, v) in [(u0, v0), (u1, v1)] { + let dv = (c - v).abs(); + if dv <= gap { + let h = (gap * gap - dv * dv).max(0.0).sqrt(); + take(u - h, u + h); + } + } + // The band along the segment: points whose foot falls on it, within the gap of it. + let (du, dv) = (u1 - u0, v1 - v0); + let len = du.hypot(dv); + if len > 0.0 { + // Each condition is linear in the position x along the line: k·x + m ≥ 0. + let mut range = (f64::NEG_INFINITY, f64::INFINITY); + let mut keep = |k: f64, m: f64| { + if k == 0.0 { + if m < 0.0 { + range = (1.0, 0.0); + } + } else if k > 0.0 { + range.0 = range.0.max(-m / k); + } else { + range.1 = range.1.min(-m / k); + } + }; + // The foot at or after a: (x - u0)·du + (c - v0)·dv ≥ 0. + keep(du, -u0 * du + (c - v0) * dv); + // The foot at or before b. + keep(-du, u0 * du - (c - v0) * dv + len * len); + // Within the gap on either side: |(x - u0)·dv - (c - v0)·du| ≤ gap·len. + let cross0 = -u0 * dv - (c - v0) * du; + keep(-dv, -cross0 + gap * len); + keep(dv, cross0 + gap * len); + if range.0 <= range.1 { + take(range.0, range.1); + } + } + (lo <= hi).then_some((lo, hi)) +} + +/// How many weeding lines run each way. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Counts { + pub horizontal: usize, + pub vertical: usize, +} + +/// The weeding lines across `frame`: `n.horizontal` horizontal and `n.vertical` vertical lines that +/// divide it evenly, each from one side of the frame to the other, left out where it is inside the +/// region's area or within `gap` of one of its outlines (`outline`). Pieces are in order along each +/// line, the horizontal lines first, top to bottom. Errs past [`MAX_NEAR`] pairs of an outline +/// segment and a line it comes within `gap` of, past [`MAX_NEAR`] crossings of the region's +/// outlines with the lines one way, or past `max_pieces` pieces. +pub(super) fn lines(region: &Region, outline: &[Segment], frame: Rect, n: Counts, gap: f64, max_pieces: usize) -> Result, String> { + let mut out = vec![]; + let (layout, _) = Layout::of(region); + for (vertical, count) in [(false, n.horizontal), (true, n.vertical)] { + let ((u0, v0), (u1, v1)) = (uv(Point::new(frame.x0, frame.y0), vertical), uv(Point::new(frame.x1, frame.y1), vertical)); + let step = (v1 - v0) / (count as f64 + 1.0); + // A frame with no height (for horizontal lines) or no width has no waste to divide. + if step <= 0.0 { + continue; + } + // The lines (0-based) each outline segment comes within `gap` of: lines k (1-based) lie at + // v0 + k·step. + let reach = |&(a, b): &Segment| { + let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1); + let (lo, hi) = (va.min(vb) - gap, va.max(vb) + gap); + let first = ((lo - v0) / step).ceil().max(1.0); + let last = ((hi - v0) / step).floor().min(count as f64); + if step > 0.0 && first <= last { (first as usize - 1)..(last as usize) } else { 0..0 } + }; + // The outlines near each line, found once for all of them, by index into `outline`. + let pairs = outline.iter().fold(0usize, |sum, s| sum.saturating_add(reach(s).len())); + if pairs > MAX_NEAR { + return Err("the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap".into()); + } + let mut near_line: Vec> = vec![vec![]; count]; + for (j, s) in outline.iter().enumerate() { + for k in reach(s) { + if let Some(list) = near_line.get_mut(k) { + list.push(j); + } + } + } + let crossed = crossings(region, vertical, v0, step, count)?; + for (i, segments) in near_line.iter().enumerate() { + let c = v0 + step * (i as f64 + 1.0); + let mut blocked = region.spans_on(&layout, crossed.get(i).map(Vec::as_slice).unwrap_or_default(), 0); + blocked.extend(segments.iter().filter_map(|&j| outline.get(j)).filter_map(|&(a, b)| near(a, b, vertical, c, gap))); + let mut from = u0; + let point = |u: f64| if vertical { Point::new(c, u) } else { Point::new(u, c) }; + for (a, b) in merged(blocked) { + if a - from >= MIN_PIECE && a <= u1 { + out.push((point(from), point(a.min(u1)))); + } + from = from.max(b); + } + if u1 - from >= MIN_PIECE { + out.push((point(from), point(u1))); + } + if out.len() > max_pieces { + return Err(format!("the weeding lines would be more than {max_pieces} pieces")); + } + } + } + Ok(out) +} diff --git a/crates/engine/src/cmd/addons/cutting/weed/tests.rs b/crates/engine/src/cmd/addons/cutting/weed/tests.rs new file mode 100644 index 0000000000..1003ef1db3 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/weed/tests.rs @@ -0,0 +1,915 @@ +//! Tests for `addon.cutting.weed`: a weed box around the selected art and weeding lines across it +//! that stop at the art's outlines. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Appearance, Node, NodeKind}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes}; + +use super::super::super::super::pathops::text_outline; +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId { + let anchors: Vec = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +/// Runs Weed Box and Lines with `p` and gives the group it made. +fn weed(s: &mut Session, p: Value) -> (Value, Node) { + let r = s.execute("addon.cutting.weed", &p).unwrap(); + let g = node(s, id_of(&r)); + (r, g) +} + +/// The weed box's rectangle in group `g`, if it has one. +fn weed_box(g: &Node) -> Option { + g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).map(|c| c.geometric_bounds().unwrap()) +} + +/// The weeding lines in group `g` as their two ends, rounded to 1/1000 pt. +fn lines(g: &Node) -> Vec<((f64, f64), (f64, f64))> { + let r = |v: f64| (v * 1000.0).round() / 1000.0; + g.children() + .unwrap() + .iter() + .filter(|c| c.name.is_none()) + .map(|c| match &c.kind { + NodeKind::Path { path, .. } => { + let a: Vec = path.anchors().map(|(_, _, a)| a.p).collect(); + assert_eq!(a.len(), 2, "a weeding line is one straight segment"); + ((r(a[0].x), r(a[0].y)), (r(a[1].x), r(a[1].y))) + } + other => panic!("not a path: {other:?}"), + }) + .collect() +} + +#[test] +fn a_weed_box_and_lines_go_around_a_rectangle() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let undo = s.doc().unwrap().history.undo.len(); + let (r, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(g.name.as_deref(), Some("Weeding")); + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0))); + // One line each way through the middle, left out over the art. + assert_eq!( + lines(&g), + [((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (160.0, 120.0)), ((125.0, 90.0), (125.0, 100.0)), ((125.0, 140.0), (125.0, 150.0))] + ); + assert_eq!(r["lines"], json!(4)); + assert_eq!(r["box"], json!(weed_box_id(&g))); + // Stroked in Registration with no fill, selected, as one undo step that takes it all away. + for c in g.children().unwrap() { + assert_eq!(c.appearance.fill_paint(), Paint::None, "no fill"); + assert!(c.appearance.stroke_paint().is_registration()); + } + assert_eq!(s.doc().unwrap().selection.objects, [g.id]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g.id).is_none()); +} + +fn weed_box_id(g: &Node) -> u64 { + g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).unwrap().id.0 +} + +#[test] +fn lines_stop_at_the_outline_and_cross_the_hole_of_a_compound_path() { + let mut s = session(); + let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0); + select(&mut s, &[outer, inner]); + let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + // Both subpaths wind the same way, so the fill rule alone decides whether the inner one is a + // hole. + let same_way = |rule: FillRule| { + move |d: &mut vectorcraft_doc::Document, _: &mut vectorcraft_doc::Selection| -> Result<()> { + let Some(NodeKind::Compound { children, rule: r }) = d.node_mut(c).map(|n| &mut n.kind) else { panic!("not a compound path") }; + *r = rule; + let area = |n: &Node| match &n.kind { + NodeKind::Path { path, .. } => path.subpaths[0].signed_area(), + _ => panic!("not a path"), + }; + let outer = area(&children[0]).signum(); + for child in children.iter_mut() { + if area(child).signum() != outer + && let NodeKind::Path { path, .. } = &mut std::sync::Arc::make_mut(child).kind + { + path.reverse(); + } + } + Ok(()) + } + }; + s.edit("even-odd", same_way(FillRule::EvenOdd)).unwrap(); + select(&mut s, &[c]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The waste in the hole is cut too; the ring itself is not. + assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((130.0, 150.0), (170.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]); + // With the non-zero rule the inner subpath fills, and no line crosses it. + s.edit("non-zero", same_way(FillRule::NonZero)).unwrap(); + select(&mut s, &[c]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]); +} + +#[test] +fn a_gap_keeps_the_lines_away_from_the_outline() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let (_, g) = weed(&mut s, json!({"margin": 20, "gap": 5, "direction": "horizontal"})); + assert_eq!(weed_box(&g), Some(Rect::new(80.0, 80.0, 170.0, 160.0))); + assert_eq!(lines(&g), [((80.0, 120.0), (95.0, 120.0)), ((155.0, 120.0), (170.0, 120.0))]); + // The gap is a distance from the outline: a line passing 5 pt beside a corner, with a gap of + // 6, stops √11 pt short of the corner's height. + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let b = rect(&mut s, 200.0, 60.0, 10.0, 120.0); + select(&mut s, &[a, b]); + let (_, g) = weed(&mut s, json!({"margin": 0, "gap": 6, "direction": "vertical"})); + assert_eq!(weed_box(&g), Some(Rect::new(100.0, 60.0, 210.0, 180.0))); + assert_eq!(lines(&g), [((155.0, 60.0), (155.0, 96.683)), ((155.0, 143.317), (155.0, 180.0))]); +} + +#[test] +fn a_box_with_no_height_gets_no_lines_across_it() { + // A horizontal line with no margin: the box is as flat as the line, and no weeding line runs + // along it. + let mut s = session(); + let l = line(&mut s, &[(100.0, 150.0), (200.0, 150.0)]); + let n = s.doc().unwrap().doc.node_count(); + assert!(s.execute("addon.cutting.weed", &json!({"margin": 0, "direction": "horizontal", "box": false})).is_err()); + assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing to add"); + select(&mut s, &[l]); + let (_, g) = weed(&mut s, json!({"margin": 0, "direction": "horizontal"})); + assert_eq!(weed_box(&g), Some(Rect::new(100.0, 150.0, 200.0, 150.0))); + assert_eq!(lines(&g), []); +} + +#[test] +fn an_open_path_stops_a_line_where_it_crosses() { + let mut s = session(); + line(&mut s, &[(100.0, 100.0), (100.0, 200.0)]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal", "gap": 2})); + assert_eq!(lines(&g), [((90.0, 150.0), (98.0, 150.0)), ((102.0, 150.0), (110.0, 150.0))]); +} + +#[test] +fn type_is_measured_by_its_glyph_outlines() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "OHO", "size": 72})).unwrap()); + let glyphs = match &node(&s, t).kind { + NodeKind::Text(t) => text_outline(t), + _ => panic!("not type"), + }; + let ink = glyphs.bounds().unwrap(); + let (_, g) = weed(&mut s, json!({"margin": 10, "count": 3, "direction": "horizontal"})); + let b = weed_box(&g).unwrap(); + for (got, want) in [(b.x0, ink.x0 - 10.0), (b.y0, ink.y0 - 10.0), (b.x1, ink.x1 + 10.0), (b.y1, ink.y1 + 10.0)] { + assert!((got - want).abs() < 0.05, "the box is 10 pt around the glyphs: {b:?} vs {ink:?}"); + } + // No piece of line runs over a glyph: each piece's middle is outside the ink. + let shape = glyphs.to_bezpath(); + let pieces = lines(&g); + assert!(pieces.len() > 3, "the letters break the lines: {pieces:?}"); + for ((x0, y0), (x1, y1)) in pieces { + let mid = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0); + assert_eq!(shape.winding(mid), 0, "{mid:?} is outside the glyphs"); + } +} + +#[test] +fn a_clip_group_is_measured_by_what_shows() { + let mut s = session(); + let big = rect(&mut s, 0.0, 0.0, 400.0, 300.0); + let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + select(&mut s, &[big, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let (_, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0))); + assert_eq!(lines(&g).len(), 4); + // Art that shows only partly: the clip path's edge where the art is, and the art's edge + // inside the clip. + let mut s = session(); + let art = rect(&mut s, 120.0, 0.0, 100.0, 300.0); + let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + select(&mut s, &[art, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(weed_box(&g), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + assert_eq!(lines(&g), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]); +} + +#[test] +fn a_group_as_the_clipping_path_clips_by_its_members() { + // Make Clipping Mask takes a path, a compound path or type; an opened file can clip by a + // group, as this one does. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("clip group", |d, sel| { + let mut r = |x: f64, w: f64, y: f64, h: f64| { + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + w, y + h)), Appearance::default())) + }; + let (a, b, art) = (r(100.0, 50.0, 100.0, 40.0), r(250.0, 50.0, 100.0, 40.0), r(0.0, 400.0, 0.0, 300.0)); + let clip = Arc::new(Node::group(d.alloc_id(), vec![a, b])); + let mut g = Node::group(d.alloc_id(), vec![clip, art]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + let id = g.id; + d.insert(parent, usize::MAX, g)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The art shows through both rectangles, and the line is cut between them too. + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 310.0, 150.0))); + assert_eq!(lines(&g), [((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (250.0, 120.0)), ((300.0, 120.0), (310.0, 120.0))]); +} + +#[test] +fn lines_near_too_many_outline_segments_are_refused() { + let mut s = session(); + // A zigzag of 12,000 segments; with a gap as tall as the box, each of 1,000 lines comes near + // every segment. + let mut zigzag = BezPath::new(); + zigzag.move_to((100.0, 100.0)); + for i in 1..=12_000 { + zigzag.line_to((100.0 + f64::from(i) * 0.01, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("zigzag", |d, sel| { + let n = Node::path(d.alloc_id(), PathData::from_bezpath(&zigzag), Appearance::default()); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({"count": 1000, "gap": 50, "direction": "horizontal"})).unwrap_err(); + assert!(e.to_string().contains("too many outline segments"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); + // Fewer lines measure. + s.execute("addon.cutting.weed", &json!({"count": 10, "gap": 50, "direction": "horizontal"})).unwrap(); +} + +/// Adds a clip group, clipping path `clip` over `art` (both unpainted), and selects it. +fn clip_group(s: &mut Session, clip: PathData, art: PathData) -> NodeId { + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("clip group", |d, sel| { + let clip = Arc::new(Node::path(d.alloc_id(), clip, Appearance::default())); + let art = Arc::new(Node::path(d.alloc_id(), art, Appearance::default())); + let mut g = Node::group(d.alloc_id(), vec![clip, art]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + let id = g.id; + d.insert(parent, usize::MAX, g)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(s, &[id]); + id +} + +#[test] +fn an_open_clipping_path_clips_as_if_closed() { + let mut s = session(); + let mut v = BezPath::new(); + v.move_to((100.0, 100.0)); + v.line_to((150.0, 200.0)); + v.line_to((200.0, 100.0)); + clip_group(&mut s, PathData::from_bezpath(&v), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 300.0))); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The art shows inside the triangle the V closes; the line stops at its sides. + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 210.0, 210.0))); + assert_eq!(lines(&g), [((90.0, 150.0), (125.0, 150.0)), ((175.0, 150.0), (210.0, 150.0))]); +} + +#[test] +fn art_a_nested_clip_hides_adds_no_outline() { + // A clip group, clipping path A, holding a clip group whose clipping path B shows a small + // rectangle and hides a line that runs along A's bottom edge. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("nested clips", |d, sel| { + let mut path = |p: PathData| Arc::new(Node::path(d.alloc_id(), p, Appearance::default())); + let mut l = BezPath::new(); + l.move_to((0.0, 200.0)); + l.line_to((400.0, 200.0)); + let (b, shown, hidden) = ( + path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0))), + path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0))), + path(PathData::from_bezpath(&l)), + ); + let a = path(shapes::rectangle(Rect::new(50.0, 50.0, 300.0, 200.0))); + let clipped = |mut g: Node| { + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + g + }; + let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, hidden]))); + let outer = clipped(Node::group(d.alloc_id(), vec![a, inner])); + let id = outer.id; + d.insert(parent, usize::MAX, outer)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let (_, g) = weed(&mut s, json!({"margin": 10, "count": 0})); + assert_eq!(weed_box(&g), Some(Rect::new(100.0, 100.0, 150.0, 140.0)), "only the shown rectangle is measured"); +} + +#[test] +fn art_a_nested_clip_hides_counts_toward_the_clip_limit() { + // A clip group, clipping path A of 20,000 segments, holding a clip group whose small clipping + // path B shows a rectangle and hides a closed zigzag C of 20,000 points across every row of A. + // The inner group shows four outline pieces, but testing each piece of A for being inside it + // looks at all of C. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("nested clips", |d, sel| { + let mut path = |p: BezPath| Arc::new(Node::path(d.alloc_id(), PathData::from_bezpath(&p), Appearance::default())); + let mut a = BezPath::new(); + a.move_to((0.0, 0.0)); + for i in 1..=20_000 { + a.line_to((f64::from(i) * 0.02, if i % 2 == 0 { 0.0 } else { 0.5 })); + } + a.line_to((400.0, 300.0)); + a.line_to((0.0, 300.0)); + a.close_path(); + let mut c = BezPath::new(); + c.move_to((300.0, 0.0)); + for i in 1..=20_000 { + c.line_to((300.0 + f64::from(i) * 0.002, if i % 2 == 0 { 0.0 } else { 300.0 })); + } + c.close_path(); + let b = path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)).to_bezpath()); + let shown = path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0)).to_bezpath()); + let (a, c) = (path(a), path(c)); + let clipped = |mut g: Node| { + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + g + }; + let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, c]))); + let outer = clipped(Node::group(d.alloc_id(), vec![a, inner])); + let id = outer.id; + d.insert(parent, usize::MAX, outer)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({"margin": 10, "count": 0})).unwrap_err(); + assert!(e.to_string().contains("too many outline segments"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); +} + +#[test] +fn art_selected_inside_a_clip_group_is_measured_by_what_shows() { + // The rectangle runs far outside the clipping path; selected on its own, it still shows only + // inside it. + let mut s = session(); + let clip = shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)); + let g = clip_group(&mut s, clip, shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0))); + let art = node(&s, g).children().unwrap()[1].id; + select(&mut s, &[art]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + assert_eq!(lines(&w), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]); + // The clipping path selected on its own is measured by the art it shows. + let clip_path = node(&s, g).children().unwrap()[0].id; + select(&mut s, &[clip_path]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + // With the art selected too, or the group, the group is measured once. + let d = &s.doc().unwrap().doc; + let outline = |ids: &[NodeId]| super::geometry::Reader::new(d).read_ids(ids).unwrap().unwrap().outline().unwrap().len(); + assert_eq!(outline(&[clip_path, art]), outline(&[g])); + assert_eq!(outline(&[g, art, g]), outline(&[g])); +} + +#[test] +fn art_in_a_hidden_group_is_left_out() { + let mut s = session(); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let b = rect(&mut s, 200.0, 100.0, 50.0, 40.0); + select(&mut s, &[a, b]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(g) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[a]); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "nothing selected shows"); + select(&mut s, &[a, shown]); + let (_, w) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); +} + +#[test] +fn art_in_a_hidden_group_is_not_measured() { + // Selected art in a hidden group with more outline points than the limit doesn't count toward + // it. + let mut s = session(); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let mut p = BezPath::new(); + p.move_to((100.0, 100.0)); + for i in 1..=super::geometry::MAX_POINTS as u32 { + p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + let dense = insert(&mut s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default())); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(g) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[dense, shown]); + let (_, w) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); +} + +#[test] +fn clip_outlines_crossing_too_often_are_refused() { + // A clipping path whose 700 edges each run across the art, and open art whose 700 segments + // each run down across the clipping path: every pair crosses, and each of the 490,000 pieces + // of art between crossings would be tested against all 700 edges. + let zigzag = |across: bool| { + let mut p = BezPath::new(); + for i in 0..=700 { + let (along, side) = (100.0 + f64::from(i) * 0.25, if i % 2 == 0 { 100.0 } else { 300.0 }); + let pt = if across { (side, along) } else { (along, side) }; + if i == 0 { p.move_to(pt) } else { p.line_to(pt) } + } + p + }; + let mut clip = zigzag(true); + clip.close_path(); + let mut s = session(); + clip_group(&mut s, PathData::from_bezpath(&clip), PathData::from_bezpath(&zigzag(false))); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("too many outline segments"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); +} + +/// Each weeding line's stretches inside the art, found from the crossings indexed once for all the +/// lines, match those found by measuring the whole art along that line. +#[test] +fn indexed_crossings_find_the_same_stretches() { + let mut s = session(); + let mut ids = vec![]; + let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0); + select(&mut s, &[outer, inner]); + ids.push(id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap())); + let big = rect(&mut s, 220.0, 50.0, 200.0, 200.0); + let window = id_of(&s.execute("shape.ellipse", &json!({"x": 260, "y": 80, "width": 90, "height": 120})).unwrap()); + select(&mut s, &[big, window]); + ids.push(id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap())); + ids.push(id_of(&s.execute("text.create", &json!({"x": 100, "y": 320, "text": "WEED 8", "size": 60})).unwrap())); + ids.push(line(&mut s, &[(90.0, 40.0), (450.0, 380.0)])); + let a = rect(&mut s, 300.0, 300.0, 30.0, 30.0); + let b = id_of(&s.execute("shape.ellipse", &json!({"x": 320, "y": 310, "width": 60, "height": 40})).unwrap()); + select(&mut s, &[a, b]); + ids.push(id_of(&s.execute("object.group", &json!({})).unwrap())); + let d = &s.doc().unwrap().doc; + let nodes: Vec<&Node> = ids.iter().filter_map(|id| d.node(*id)).collect(); + let region = super::geometry::Reader::new(d).read(&nodes).unwrap().unwrap(); + let (layout, _) = super::geometry::Layout::of(®ion); + let count = 97; + for vertical in [false, true] { + let (v0, v1) = (30.0, 400.0); + let step = (v1 - v0) / (count as f64 + 1.0); + let crossed = super::geometry::crossings(®ion, vertical, v0, step, count).unwrap(); + assert_eq!(crossed.len(), count); + let mut hit = 0; + for (k, line) in crossed.iter().enumerate() { + let c = v0 + step * (k as f64 + 1.0); + let want = region.spans(vertical, c); + hit += usize::from(!want.is_empty()); + assert_eq!(region.spans_on(&layout, line, 0), want, "line {k}, vertical {vertical}"); + } + assert!(hit > count / 2, "most lines cross the art ({hit})"); + } +} + +#[test] +fn art_nested_too_deep_is_refused() { + // A rectangle inside `levels` groups, each holding the next. A document keeps none this deep, + // but the art symbol instances and live objects draw nests further. + let doc = vectorcraft_doc::Document::new(800.0, 600.0); + let nested = |levels: usize| { + let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default()); + for i in 0..levels { + n = Node::group(NodeId(i as u64 + 2), vec![Arc::new(n)]); + } + n + }; + let read = |n: &Node| super::geometry::Reader::new(&doc).read(&[n]); + assert!(read(&nested(super::geometry::MAX_DEPTH)).unwrap().is_some()); + let e = read(&nested(super::geometry::MAX_DEPTH + 1)).unwrap_err(); + assert!(e.contains("nested more than"), "{e}"); +} + +#[test] +fn spacing_divides_the_box_into_strips_no_wider() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 10.0, 40.0); + // The box is 60 pt tall: three strips of 20 pt. + let (_, g) = weed(&mut s, json!({"margin": 10, "spacing": 25, "direction": "horizontal", "box": false})); + assert_eq!(weed_box(&g), None); + let ys: Vec = lines(&g).iter().map(|l| l.0.1).collect(); + assert_eq!(ys, [110.0, 110.0, 130.0, 130.0]); + let mut s = session(); + rect(&mut s, 100.0, 100.0, 10.0, 40.0); + assert!(s.execute("addon.cutting.weed", &json!({"spacing": 0.01})).is_err(), "too many lines"); +} + +#[test] +fn the_lines_take_a_swatch() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Cut", "color": "cmyk 0 100 0 0", "spot": true})).unwrap(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let (_, g) = weed(&mut s, json!({"swatch": "Cut", "weight": 1})); + for c in g.children().unwrap() { + match c.appearance.stroke_paint() { + Paint::Solid { swatch, .. } => assert_eq!(swatch.as_deref(), Some("Cut")), + other => panic!("stroke {other:?}"), + } + assert_eq!(c.appearance.stroke_width(), 1.0); + } +} + +/// Adds the object `make` makes with a new id to the current layer, selects it and gives its id. +fn insert(s: &mut Session, make: impl FnOnce(NodeId) -> Node) -> NodeId { + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("insert", |d, _| { + let n = make(d.alloc_id()); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + Ok(id) + }) + .unwrap(); + select(s, &[id]); + id +} + +/// A 100 pt square turned 45° about (300, 300): a diamond 141.42 pt across. +fn diamond_xf() -> Affine { + Affine::translate((300.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4) * Affine::translate((-50.0, -50.0)) +} + +/// The weeding lines of `g` at height `y` (to 1/1000 pt). +fn at(g: &Node, y: f64) -> Vec<(f64, f64)> { + lines(g).into_iter().filter(|((_, y0), (_, y1))| *y0 == y && *y1 == y).map(|((x0, _), (x1, _))| (x0, x1)).collect() +} + +/// The diamond's weeding with three horizontal lines and a margin of 10: the box, and where the +/// first line's left piece ends. +fn diamond_weeding(s: &mut Session) -> (Rect, f64) { + let (_, g) = weed(s, json!({"margin": 10, "count": 3, "direction": "horizontal"})); + let b = weed_box(&g).unwrap(); + let y = ((b.y0 + b.height() / 4.0) * 1000.0).round() / 1000.0; + let first = at(&g, y); + assert_eq!(first.len(), 2, "{first:?}"); + (b, first[0].1) +} + +#[test] +fn an_image_is_measured_by_its_frame_as_turned() { + let mut s = session(); + let im = vectorcraft_doc::ImageObject { + key: "image".into(), + width: 100, + height: 100, + xf: diamond_xf(), + link: None, + placement: Default::default(), + pixels: None, + }; + insert(&mut s, |id| Node::new(id, NodeKind::Image(Box::new(im)))); + let (b, end) = diamond_weeding(&mut s); + let half = 50.0 * std::f64::consts::SQRT_2; + assert!((b.x0 - (290.0 - half)).abs() < 1e-3 && (b.y1 - (310.0 + half)).abs() < 1e-3, "{b:?}"); + // A quarter of the box down, the line meets the diamond's upper left edge, not its bounds. + let y = b.y0 + b.height() / 4.0; + assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}"); +} + +#[test] +fn a_placed_file_is_measured_by_the_art_it_shows() { + let dir = crate::tests_links::Folder::new("weed-placed"); + // A file with a 60 × 40 artboard and a 20 pt wide rectangle at each side (hidden when + // `hidden`), placed centered on (200, 150) and selected. + let place = |s: &mut Session, name: &str, hidden: bool| { + let src = dir.file(name); + let mut d = vectorcraft_doc::Document::new(60.0, 40.0); + let layer = d.layers[0].id; + for x in [0.0, 40.0] { + let id = d.alloc_id(); + let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0); + let mut n = Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 40.0)), look); + n.visible = !hidden; + d.insert(Some(layer), 0, n).unwrap(); + } + crate::tests_links::write(&src, &vectorcraft_format::save_file(&d)); + let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap(); + select(s, &[NodeId(r["ids"][0].as_u64().unwrap())]); + }; + let mut s = session(); + place(&mut s, "pair.vectorcraft", false); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The rectangles lie at 170..190 and 210..230; the line runs between them too. + assert_eq!(at(&g, 150.0), [(160.0, 170.0), (190.0, 210.0), (230.0, 240.0)]); + // A file read but shown in a box with no size shows nothing, and its box isn't measured. + let id = NodeId(s.execute("file.place", &json!({"path": dir.file("pair.vectorcraft"), "at": [400, 150]})).unwrap()["ids"][0].as_u64().unwrap()); + s.edit("no size", |d, _| { + if let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) { + (p.width, p.height) = (0.0, 0.0); + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[id]); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("has no outlines"), "{e}"); + // A file read with nothing showing has no outlines, and its box isn't measured instead. + let mut s = session(); + place(&mut s, "hidden.vectorcraft", true); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("has no outlines"), "{e}"); + // A file whose art is an instance of a symbol of its own: the instance's art is measured. + let src = dir.file("symbol.vectorcraft"); + let mut d = vectorcraft_doc::Document::new(60.0, 40.0); + let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0); + let art = Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 40.0)), look); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(art) }); + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY })).unwrap(); + crate::tests_links::write(&src, &vectorcraft_format::save_file(&d)); + let mut s = session(); + let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap(); + select(&mut s, &[NodeId(r["ids"][0].as_u64().unwrap())]); + let (_, g) = weed(&mut s, json!({"margin": 10})); + let b = weed_box(&g).unwrap(); + assert!(b.x0 >= 160.0 - 1e-6 && b.x1 <= 240.0 + 1e-6 && b.width() < 41.0, "the bar, not the whole box: {b:?}"); +} + +#[test] +fn a_placed_file_that_cant_be_read_is_measured_by_its_box_as_turned() { + let mut s = session(); + let p = vectorcraft_doc::PlacedDocument { + link: vectorcraft_doc::LinkInfo::new("/no/such/file.pdf"), + key: "missing".into(), + bounding: false, + page_box: Default::default(), + width: 100.0, + height: 100.0, + xf: diamond_xf(), + placement: Default::default(), + }; + insert(&mut s, |id| Node::new(id, NodeKind::PlacedDocument(Box::new(p)))); + let (b, end) = diamond_weeding(&mut s); + let half = 50.0 * std::f64::consts::SQRT_2; + let y = b.y0 + b.height() / 4.0; + assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}"); +} + +#[test] +fn a_symbol_instance_is_measured_by_its_art() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0); + let b = rect(&mut s, 140.0, 100.0, 20.0, 40.0); + select(&mut s, &[a, b]); + let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap()); + select(&mut s, &[inst]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The line runs between the two rectangles too. + assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 140.0), (160.0, 170.0)]); + // An instance of a symbol the document doesn't have draws nothing, and has no outlines. + s.edit("lose the symbol", |d, _| { + if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(inst).map(|n| &mut n.kind) { + *symbol = "Missing".into(); + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[inst]); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("has no outlines"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + select(&mut s, &[inst, shown]); + let (_, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); +} + +#[test] +fn a_blend_is_measured_by_the_art_it_draws() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0); + let b = rect(&mut s, 200.0, 100.0, 20.0, 40.0); + let blend = id_of(&s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap()); + select(&mut s, &[blend]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The step halfway between the two rectangles stops the line too. + assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 150.0), (170.0, 200.0), (220.0, 230.0)]); +} + +#[test] +fn the_group_goes_on_top_of_the_current_layer_and_hidden_layers_are_left_out() { + let mut s = session(); + let first = s.doc().unwrap().doc.layers[0].id; + let hidden = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap()); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let below = rect(&mut s, 400.0, 400.0, 20.0, 20.0); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(first) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[hidden]); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "art in a hidden layer doesn't show"); + select(&mut s, &[hidden, shown]); + let (_, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); + let d = &s.doc().unwrap().doc; + assert_eq!(d.parent_of(g.id), Some(art)); + assert_eq!(d.node(art).unwrap().children().unwrap().last().map(|c| c.id), Some(g.id), "on top"); + // A hidden object selected on its own shows nothing. + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(below) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[below]); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "a hidden object doesn't show"); +} + +#[test] +fn the_group_goes_outside_a_clip_group_edited_in_isolation() { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id; + let g = clip_group(&mut s, shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0))); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + let art = node(&s, g).children().unwrap()[1].id; + select(&mut s, &[art]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The clipping path would hide the lines and the box around the art inside it. + let d = &s.doc().unwrap().doc; + assert_eq!(d.parent_of(w.id), Some(layer)); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + // A plain group edited in isolation inside a clip group: the group goes outside the clip group. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let (inner, art) = s + .edit("clip group around a group", |d, _| { + let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default())); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0)), Appearance::default()); + let art_id = art.id; + let inner = Node::group(d.alloc_id(), vec![Arc::new(art)]); + let inner_id = inner.id; + let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(inner)]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + d.insert(parent, usize::MAX, g)?; + Ok((inner_id, art_id)) + }) + .unwrap(); + s.execute("object.isolate", &json!({"id": inner.0})).unwrap(); + select(&mut s, &[art]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(s.doc().unwrap().doc.parent_of(w.id), parent); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); +} + +/// Adds one path of `count` open vertical subpaths 2 pt apart, from (100, 100) down to y = 200. +fn bars(s: &mut Session, count: u32) -> NodeId { + let mut p = BezPath::new(); + for i in 0..count { + let x = 100.0 + f64::from(i) * 2.0; + p.move_to((x, 100.0)); + p.line_to((x, 200.0)); + } + insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default())) +} + +#[test] +fn more_than_the_most_pieces_of_line_is_refused() { + // 1,000 lines each cut into 100 pieces by 101 bars: as many as one run adds. + let mut s = session(); + bars(&mut s, 101); + let r = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap(); + assert_eq!(r["lines"], json!(super::MAX_PIECES)); + // One bar more makes 1,000 pieces more. + let mut s = session(); + bars(&mut s, 102); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap_err(); + assert!(e.to_string().contains("more than 100000 pieces"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); +} + +#[test] +fn art_with_more_than_the_most_outline_points_is_refused() { + // An open zigzag of `points` points: one subpath, measured by its points. + let zigzag = |s: &mut Session, points: u32| { + let mut p = BezPath::new(); + p.move_to((100.0, 100.0)); + for i in 1..points { + p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default())); + }; + let mut s = session(); + zigzag(&mut s, super::geometry::MAX_POINTS as u32); + s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap(); + let mut s = session(); + zigzag(&mut s, super::geometry::MAX_POINTS as u32 + 1); + let e = s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap_err(); + assert!(e.to_string().contains("too many outline points"), "{e}"); +} + +#[test] +fn bad_params_and_an_empty_selection_are_refused() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + for p in [ + json!({"margin": -1}), + json!({"gap": "x"}), + json!({"count": 1.5}), + json!({"count": 1001}), + json!({"count": 2, "spacing": 10}), + json!({"spacing": 0}), + json!({"direction": "diagonal"}), + json!({"box": "yes"}), + json!({"weight": 0}), + json!({"swatch": "no such swatch"}), + json!({"swatch": 3}), + json!({"box": false, "count": 0}), + ] { + let n = s.doc().unwrap().doc.node_count(); + assert!(s.execute("addon.cutting.weed", &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing added for {p}"); + } + // The same art with good params is weeded; with nothing selected it is refused. + s.execute("addon.cutting.weed", &json!({"count": 2, "gap": "1 mm"})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err()); +} diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index fb8af2b11e..76b4e1a715 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure|tilt|bearing|rotation, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest; tilt: width with the pen lying flat to width_max upright; bearing and rotation: width at −180° to width_max at 180°; half way on a stroke drawn without that input), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), diff --git a/crates/engine/src/cmd/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 { "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, 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 { } } -/// The type object `n` holds and the runs its layout draws, when it uses a pattern or a gradient: -/// the runs with a glyph outline (list markers and ruby included) or an underline or strikethrough -/// bar. Spaces and line breaks draw no outline. A type object's characters carry their own fill and -/// stroke, and the type object's own fills and strokes paint on its characters, so only those runs -/// paint. The type is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the -/// renderer lays it out. -fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet)> { +/// The names of `d`'s spot color swatches: the swatches whose prints go to a plate of their own +/// ([`vectorcraft_doc::inks::inks`]). A name held by several swatches is the first one's, as +/// [`Document::swatch`] finds it, and the Registration swatch prints on every plate, so it is not +/// one. +fn spot_swatches(d: &Document) -> HashSet<&str> { + let mut first: BTreeMap<&str, bool> = BTreeMap::new(); + for s in d.swatches_iter() { + first.entry(s.name.as_str()).or_insert(s.spot && s.paint.color().is_some()); + } + first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect() +} + +/// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is +/// linked to, or those a gradient's stops are linked to, each once, in the order of the stops. A +/// link at a tint of 0 prints no ink, a gradient whose stops are all at an opacity of 0 draws +/// nothing, and a color that only looks like a spot color is not linked to one. +fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec { + let mut out: Vec = vec![]; + let mut add = |swatch: &Option, tint: f32| { + if let Some(name) = swatch + && tint > 0.0 + && spots.contains(name.as_str()) + && !out.contains(name) + { + out.push(name.clone()); + } + }; + match p { + Paint::Solid { swatch, tint, .. } => add(swatch, *tint), + Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity > 0.0) => g.gradient.stops.iter().for_each(|s| add(&s.swatch, s.tint)), + _ => {} + } + out +} + +/// The type object `n` holds and the runs its layout draws, when it uses a spot color, a pattern or +/// a gradient (`spots`: [`spot_swatches`]): the runs with a glyph outline (list markers and ruby +/// included) or an underline or strikethrough bar. Spaces and line breaks draw no outline. A type +/// object's characters carry their own fill and stroke, and the type object's own fills and strokes +/// paint on its characters, so only those runs paint. The type is laid out with its inline +/// graphics' bounds resolved from `d`'s symbols, as the renderer lays it out. +fn painted_runs<'a>(d: &Document, spots: &HashSet<&str>, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet)> { let NodeKind::Text(t) = &n.kind else { return None }; - let is_listed = |p: &Paint| matches!(p, Paint::Pattern { .. } | Paint::Gradient(_)); + let is_listed = |p: &Paint| matches!(p, Paint::Pattern { .. } | Paint::Gradient(_)) || !spot_labels(spots, p).is_empty(); let own = n.appearance.items.iter().any(|i| match i { vectorcraft_doc::AppearanceItem::Fill(f) => is_listed(&f.paint), vectorcraft_doc::AppearanceItem::Stroke(s) => is_listed(&s.paint), @@ -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 + '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)>, - label: fn(&Paint) -> Option, + label: &PaintNames<'_>, ) -> Vec { if matches!(n.kind, NodeKind::Path { guide: true, .. }) || members.contains(&std::ptr::from_ref(n)) || painted_runs.is_some_and(|(_, r)| r.is_empty()) - || !n.appearance.items.iter().any(|i| label(i.paint()).is_some()) + || n.appearance.items.iter().all(|i| label(i.paint()).is_empty()) { return vec![]; } - // The patterns or gradients an image's shaped fills and strokes paint (their effects can - // transform the paint, so they are matched by label). + // The names an image's shaped fills and strokes paint (their effects can transform the paint, + // so they are matched by name). let shaped: Option> = matches!(n.kind, NodeKind::Image(_)) - .then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().filter_map(|(_, _, item)| label(item.paint())).collect()); + .then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().flat_map(|(_, _, item)| label(item.paint())).collect()); let mut out: Vec = vec![]; for item in &n.appearance.items { - let Some(name) = label(item.paint()) else { continue }; - let paints = item.visible() - && !matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) - && shaped.as_ref().is_none_or(|s| s.contains(&name)); - if paints && !out.contains(&name) { - out.push(name); + if !item.visible() || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) { + continue; + } + for name in label(item.paint()) { + if shaped.as_ref().is_none_or(|s| s.contains(&name)) && !out.contains(&name) { + out.push(name); + } } } out } +/// [`pattern_label`] or [`gradient_label`] as the names [`own_paints`] takes. +fn one_label(label: fn(&Paint) -> Option) -> impl Fn(&Paint) -> Vec { + move |p| label(p).into_iter().collect() +} + fn info(s: &mut Session, p: &Value) -> Result { const C: &str = "document.info"; let category = str_param(p, "category"); @@ -370,7 +417,13 @@ fn info(s: &mut Session, p: &Value) -> Result { }; let mut patterns = BTreeSet::new(); let mut symbols = BTreeSet::new(); + // `spot_objects`, `spot_rows` and `spot` hold the `spotObjects` entries, the rows of the objects filled or stroked with a spot + // color, in walk order, and the spot colors they use. `spot_swatches` names the document's spot colors once, for every paint. + let spot_swatches = spot_swatches(d); + let mut spot_objects: Vec = vec![]; + let mut spot_rows: Vec<(String, String)> = vec![]; let mut spot = BTreeSet::new(); + let (spot_label, pattern, gradient) = (|p: &Paint| spot_labels(&spot_swatches, p), one_label(pattern_label), one_label(gradient_label)); let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() }; // `brushes` holds the Brushes panel's brushes, or with selectionOnly it holds the brushes that the selected objects' strokes use. let mut brushes: Vec = if selection_only { vec![] } else { vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect() }; @@ -446,17 +499,11 @@ fn info(s: &mut Session, p: &Value) -> Result { if let Some(k) = k { *counts.entry(k).or_default() += 1; } - for item in &n.appearance.items { - if let Paint::Solid { swatch: Some(name), .. } = item.paint() - && d.swatch(name).is_some_and(|s| s.spot) - { - spot.insert(name.clone()); - } - } - // The patterns and the gradients this object's fills and strokes paint, each once. - let painted_runs = painted_runs(d, n); - let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), pattern_label); - let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), gradient_label); + // The spot colors, the patterns and the gradients this object's fills and strokes paint, each once. + let painted_runs = painted_runs(d, &spot_swatches, n); + let mut used_spots = own_paints(d, n, &members, painted_runs.as_ref(), &spot_label); + let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), &pattern); + let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), &gradient); // The characters' own paints: an inline graphic draws no outline and does not take its // run's paint (a list marker set in its run does), and a stroke with no width paints // nothing. @@ -464,19 +511,38 @@ fn info(s: &mut Session, p: &Value) -> Result { for run in painted.iter().filter_map(|i| t.runs.get(*i)) { let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke); for paint in std::iter::once(&run.style.fill).chain(stroke) { - for (used, label) in - [(&mut used_patterns, pattern_label as fn(&Paint) -> Option), (&mut used_gradients, gradient_label)] - { - if let Some(name) = label(paint) - && !used.contains(&name) - { - used.push(name); + let labels: [(&mut Vec, &PaintNames<'_>); 3] = + [(&mut used_spots, &spot_label), (&mut used_patterns, &pattern), (&mut used_gradients, &gradient)]; + for (used, label) in labels { + for name in label(paint) { + if !used.contains(&name) { + used.push(name); + } } } } } } - // Objects counts the objects that Pattern Objects and Gradient Objects list. + // A grayscale or 1-bit image colorized with a spot color prints it, while its pixels take + // the color, as the renderer draws it. + if let NodeKind::Image(im) = &n.kind + && let Some(colorize) = n.extras().colorize.as_deref() + && d.image_blob(im).is_some_and(|b| b.takes_colorize()) + { + for name in spot_label(colorize) { + if !used_spots.contains(&name) { + used_spots.insert(0, name); + } + } + } + // Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list. + if !used_spots.is_empty() { + let name = n.display_name(); + spot.extend(used_spots.iter().cloned()); + spot_rows.push((name.clone(), used_spots.join(", "))); + spot_objects.push(json!({ "id": n.id.0, "name": name, "spotColors": used_spots })); + *counts.entry("spots").or_default() += 1; + } if !used_patterns.is_empty() { let name = n.display_name(); patterns.extend(used_patterns.iter().cloned()); @@ -529,6 +595,8 @@ fn info(s: &mut Session, p: &Value) -> Result { "images": images, "symbols": symbols, "spotColors": spot, + "spotColorNames": spot, + "spotObjects": spot_objects, "swatches": d.swatches_iter().count(), "graphicStyles": d.graphic_styles.len(), "graphicStyleNames": styles, @@ -544,7 +612,7 @@ fn info(s: &mut Session, p: &Value) -> Result { }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, &gradient_rows, category); + let sections = sections(d, &out, &fonts, &image_rows, &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": "", "spotColors": ["Ink"]}, + {"id": f, "name": "", "spotColors": ["Gold"]}, + {"id": t, "name": "Type", "spotColors": ["Ink"]}, + ]) + ); + assert_eq!((i["spotColorNames"].clone(), i["spotColors"].clone()), (json!(["Gold", "Ink"]), json!(["Gold", "Ink"]))); + // Objects counts the objects that Spot Color Objects lists. + assert_eq!(i["objects"]["spots"].as_u64(), Some(4), "{}", i["objects"]); + let objects = s.execute("document.info", &json!({"category": "objects"})).unwrap()["sections"][0]["rows"].clone(); + assert!(objects.as_array().unwrap().contains(&json!(["Spot Color Objects", "4"])), "{objects}"); + s.execute("select.set", &json!({"ids": [c, g, h, d, e, empty, blank, guide, compound, image.0]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap(); + assert!(i["objects"]["spots"].is_null() && i["spotObjects"] == json!([]) && i["spotColors"] == json!([]), "{i}"); + let i = s.execute("document.info", &json!({"category": "spotColors"})).unwrap(); + assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("spotColors"), Some("Spot Color Objects"))); + assert_eq!(i["sections"][0]["rows"], json!([["Both", "Ink, Gold"], ["", "Ink"], ["", "Gold"], ["Type", "Ink"]])); + // A selection that holds the group and the object inside it lists that object once. + for ids in [json!([group, b]), json!([b, group])] { + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "spotColors"})).unwrap(); + assert_eq!(i["sections"][0]["rows"], json!([["", "Ink"]]), "{ids}"); + assert_eq!((i["objects"]["spots"].as_u64(), i["spotColorNames"].clone()), (Some(1), json!(["Ink"])), "{ids}"); + } + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "spotColors"})).unwrap(); + assert_eq!((i["sections"][0]["rows"].clone(), i["spotColors"].clone()), (json!([["Type", "Ink"]]), json!(["Ink"]))); + let text = s.execute("document.info", &json!({"category": "spotColors", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nSPOT COLOR OBJECTS\nBoth: Ink, Gold\n: Ink\n: Gold\nType: Ink\n"), "{text}"); + assert!(!text.contains("Spare") && !text.contains("Look"), "{text}"); + } + + /// Spot Color Objects lists a grayscale image colorized with a spot color, and leaves out a + /// gradient whose stops are all at an opacity of 0, which draws nothing. + #[test] + fn spot_color_objects_take_colorized_images_and_skip_clear_gradients() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"model": "cmyk", "c": 0, "m": 1, "y": 1, "k": 0}, "spot": true})).unwrap(); + let mut bytes = Vec::new(); + image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()) + .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png) + .unwrap(); + let image = s + .edit("Place", |d, sel| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(bytes)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 2, + height: 1, + xf: Default::default(), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + assert!(s.doc().unwrap().doc.node(image).unwrap().extras().colorize.is_some()); + let clear = s.execute("shape.rectangle", &json!({"x": 0, "y": 50, "width": 10, "height": 10})).unwrap()["id"].clone(); + let stops = + json!([{"offset": 0, "color": "#000", "swatch": "Ink", "opacity": 0}, {"offset": 1, "color": "#fff", "swatch": "Ink", "opacity": 0}]); + s.execute("paint.setFill", &json!({"ids": [clear], "gradient": {"kind": "linear", "stops": stops}})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [clear], "none": true})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + let name = s.doc().unwrap().doc.node(image).unwrap().display_name(); + assert_eq!(i["spotObjects"], json!([{"id": image.0, "name": name, "spotColors": ["Ink"]}]), "{i}"); + assert_eq!(i["objects"]["spots"].as_u64(), Some(1), "{}", i["objects"]); + } + /// Document Info › Objects counts an object with a gradient or a pattern on both its fill and /// its stroke once, and with Selection Only a selection that holds a group and an object inside /// it counts that object once. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4f3dc3403a..b7affffa81 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "New…", ["File"], None, - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), 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 { "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 { + 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 = [0.0, 0.25, 0.5, 0.75].iter().map(|&u| *frame * (diamond_perimeter(u).to_vec2() * t).to_point()).collect(); SubPath::polyline(&pts, true) }; - return (0..n) + return (0..edges.len().saturating_sub(1)) .rev() .map(|k| { - let mut shape = PathData::single(square((k + 1) as f64 * step)); + let mut shape = PathData::single(square(edges.get(k + 1).copied().unwrap_or(*reach))); if !opaque && k > 0 { - let mut hole = square(k as f64 * step); + let mut hole = square(edges.get(k).copied().unwrap_or(0.0)); hole.reverse(); shape.subpaths.push(hole); } - (shape, g.sample_keep_model(((k as f64 + 0.5) * step) as f32)) + (shape, color(&edges, k)) }) .collect(); } - let step = (self.t1 - self.t0) / n as f64; - let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32); + let edges = band_edges(self.t0, self.t1, n, runs); + let bands = edges.len().saturating_sub(1); + let edge = |k: usize| edges.get(k).copied().unwrap_or(self.t1); if self.radial { let ellipse = |t: f64| { let c = self.focal + (self.start - self.focal) * t; let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, c.x, c.y]); shapes::ellipse(Rect::new(-1.0, -1.0, 1.0, 1.0)).transformed(m) }; - return (0..n) + return (0..bands) .rev() .map(|k| { - let mut shape = ellipse((k + 1) as f64 * step); + let mut shape = ellipse(edge(k + 1)); if !opaque && k > 0 { - let mut hole = ellipse(k as f64 * step).subpaths.remove(0); + let mut hole = ellipse(edge(k)).subpaths.remove(0); hole.reverse(); shape.subpaths.push(hole); } - (shape, color(k)) + (shape, color(&edges, k)) }) .collect(); } - (0..n) + (0..bands) .map(|k| { - let a = self.t0 + k as f64 * step; - let b = if opaque { self.t1 } else { a + step }; + let a = edge(k); + let b = if opaque { self.t1 } else { edge(k + 1) }; let pts = [self.at(a, self.s0), self.at(b, self.s0), self.at(b, self.s1), self.at(a, self.s1)]; - (PathData::single(SubPath::polyline(&pts, true)), color(k)) + (PathData::single(SubPath::polyline(&pts, true)), color(&edges, k)) }) .collect() } diff --git a/crates/engine/src/cmd/fileio/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 { 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 { /// 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 { // 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, naming: Naming, items: Vec, + /// The export folder and the name the request that prepared the export may see it by, when the + /// folder came from the `exportPreset` and is outside that request's read roots: the reply and + /// the errors of [`ScreenExport::write`] name it as [`export_for_screens`] does. + hidden: Option<(String, String)>, } impl Session { + /// The folder `document.exportForScreens` (`id`) with `params` writes into, the client's + /// `folder` or else the one its `exportPreset` keeps, with the name the request being served may + /// see it by when the reply leaves it out: a preset's folder outside the read roots, named by + /// its own name ([`super::access::shown_path`]). `assets.export` takes no preset, so for it the + /// folder is the client's. → `None` without a folder (the files come back in the reply) or for + /// an unknown preset, which the export itself refuses. + pub fn screen_export_folder(&self, id: &str, params: &Value) -> Option<(String, Option)> { + let given = params.get("folder").is_some(); + let p = if given || id == ASSETS { params.clone() } else { with_preset(self, params).ok()? }; + let folder = str_param(&p, "folder").filter(|f| !f.is_empty())?.to_string(); + let hidden = (!given && !super::access::shown(&folder)).then(|| super::access::shown_path(&folder).into_owned()); + Some((folder, hidden)) + } + /// Run `assets.export` when `id` names it, else `document.exportForScreens`, with `params` as /// a command (checked and journaled as any other; Export for Screens remembers its settings) /// that encodes and writes nothing → the export it prepared, which [`ScreenExport::write`] @@ -1123,10 +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 { + pub fn write(mut self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { let cmd = self.cmd; + let hidden = self.hidden.take(); // A refusal names `assets.export` when that command prepared the export, as its reply does. - encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) }) + let r = encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) }); + match hidden { + Some((folder, name)) => { + let mut r = r.map_err(|e| 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_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 0b529c87c6..1b3fe3cfec 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -735,6 +735,81 @@ fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_ assert!(e.contains(&format!("root {root}: {name}")), "{e}"); } +/// A prepared export into the folder an export preset keeps, prepared for a client whose read roots +/// don't hold that folder and written later outside that client's scope (as on the background +/// thread): its reply leaves out the ZIP's path and names the files relative to the folder, and a +/// refused write names the folder by its name. A folder inside the read roots, and one the client +/// gives, stay whole. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("prepared-roots"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png", "folder": "1x"}], "subfolders": true, "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let write_only = AutomationRoots::new(&[inside.as_path()], &[out.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[out.as_path()]).unwrap(); + let prepare = |s: &mut Session, roots: &Option>, p: Value| { + confine(roots.as_ref(), || s.prepare_screen_export("document.exportForScreens", &p)).unwrap() + }; + + let (r, files) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept"}))); + assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}"); + assert!(files.contains_key(&format!("{folder}/1x/Artboard-1.png")), "the files are written into the folder"); + let (r, _) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "zip": true}))); + assert_eq!((r.get("path"), &r["files"]), (None, &json!(["1x/Artboard-1.png"])), "{r}"); + assert!(!r.to_string().contains(&folder), "{r}"); + // A refused write names the folder by its name. + let refused = prepare(&mut s, &write_only, json!({"exportPreset": "Kept"})); + let e = refused.write(&mut |path, _| Err(crate::EngineError::Other(format!("{path}: access denied")))).unwrap_err().to_string(); + assert!(e.contains("kept-out/1x/Artboard-1.png: access denied") && !e.contains(&folder), "{e}"); + // Inside the read roots, or given by the client: whole. + let (r, _) = write_prepared(prepare(&mut s, &both, json!({"exportPreset": "Kept"}))); + assert_eq!(r["files"], json!([format!("{folder}/1x/Artboard-1.png")]), "{r}"); + let (r, _) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder, "zip": true}))); + assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}"); + let given = prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder})); + let e = given.write(&mut |path, _| Err(crate::EngineError::Other(format!("{path}: access denied")))).unwrap_err().to_string(); + assert!(e.contains(&folder), "{e}"); +} + +/// [`Session::screen_export_folder`]: the client's folder, else the one the export preset keeps, +/// with its own name when the request's read roots don't hold it; none without a folder, for an +/// unknown preset or for `assets.export`, which takes no preset. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_export_folder_is_the_clients_else_the_presets() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("export-folder"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png"}], "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let ask = |id: &str, p: Value| confine(confined.as_ref(), || s.screen_export_folder(id, &p)); + + let screens = "document.exportForScreens"; + assert_eq!(ask(screens, json!({"exportPreset": "Kept"})), Some((folder.clone(), Some("kept-out".to_string())))); + assert_eq!(ask(screens, json!({"exportPreset": "Kept", "folder": "/x/y"})), Some(("/x/y".to_string(), None))); + assert_eq!(ask(screens, json!({"exportPreset": "Kept", "folder": ""})), None); + assert_eq!(ask(screens, json!({"exportPreset": "Nope"})), None); + assert_eq!(ask(screens, json!({})), None); + assert_eq!(ask("assets.export", json!({"exportPreset": "Kept"})), None); + assert_eq!(s.screen_export_folder(screens, &json!({"exportPreset": "Kept"})), Some((folder, None)), "unconfined: whole"); +} + /// The export settings the document keeps and the saved presets, read by a client whose read roots /// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and /// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest. diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 4a42f03d4f..98f53b102e 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -20,6 +20,9 @@ //! and they lie the same way relative to each other: the group as a whole is moved or scaled. //! With Appearance on, each of those objects must have the same fill and stroke too; with Size, //! the groups the same width and height. +//! - Selecting one of the repeated objects with the Selection tool highlights the similar objects +//! at once, before Global Edit starts. Here they are the objects Start Global Edit would take +//! with the current Global Edit Options ([`similar`]). //! - While Global Edit is on, an edit made to one of the found objects is made to all of them, //! each in proportion to its own size: halving the height of a 100 pt object halves a 20 pt //! match to 10 pt. Stop Global Edit (or Escape) ends it. @@ -780,18 +783,55 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se /// The outlines of the members of the active document's Global Edit that are not selected, hold /// no selected object and sit inside no selected object, in their layer's colour: the canvas -/// highlights them while Global Edit is on. +/// highlights them while Global Edit is on. While it is off, with one supported object selected, +/// they are the outlines of the objects Start Global Edit would take with the Global Edit Options +/// ([`similar`]). pub fn highlights(s: &Session) -> Outlines { let Some(st) = s.active() else { return vec![] }; - let Some(ge) = &st.global_edit else { return vec![] }; + let Some(ge) = &st.global_edit else { return similar(s) }; if let Some((rev, out)) = &*ge.outlines.borrow() && *rev == st.revision { return out.clone(); } + let out = outlines(st, &ge.members); + *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); + out +} + +/// [`similar`]'s outlines as of a document revision and the options they were found with. +pub type SimilarCache = std::cell::RefCell>; + +/// Before Global Edit starts: with one supported object selected, the outlines of the other +/// objects Start Global Edit would take now with the session's Global Edit Options, so the canvas +/// shows them as soon as the object is selected. None while an interaction (a drag) is in +/// progress, or when fewer than two objects would be taken. Kept per document revision and +/// options, as finding them walks the whole document. +pub fn similar(s: &Session) -> Outlines { + let Some(st) = s.active() else { return vec![] }; + if st.global_edit.is_some() || st.interaction.is_some() || st.selection.objects.len() != 1 { + return vec![]; + } + let o = &s.global_edit_options; + if let Some((rev, opts, out)) = &*st.similar_outlines.borrow() + && *rev == st.revision + && opts == o + { + return out.clone(); + } + let out = match matches(s, o) { + Ok(m) if m.len() >= 2 => outlines(st, &m), + _ => vec![], + }; + *st.similar_outlines.borrow_mut() = Some((st.revision, o.clone(), out.clone())); + out +} + +/// The outlines of `members` that are not selected, hold no selected object and sit inside no +/// selected object, in their layer's color. +fn outlines(st: &crate::DocState, members: &[(NodeId, Affine)]) -> Outlines { let symbols = st.doc.symbol_boxes(); - let out: Vec<_> = ge - .members + members .iter() .filter_map(|(id, _)| { let n = st.doc.node(*id).filter(|n| !holds(n, &st.selection.objects))?; @@ -804,9 +844,7 @@ pub fn highlights(s: &Session) -> Outlines { }; Some((path, st.doc.layer_color(*id))) }) - .collect(); - *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); - out + .collect() } #[cfg(test)] diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 9003e325a9..090a7cb46c 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -1,7 +1,8 @@ //! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector. use serde_json::{Value, json}; -use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint}; +use vectorcraft_color::gradient::MAX_REPEATS; +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientSpread, GradientStop, Paint}; use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode}; use vectorcraft_geom::{Affine, Point, Rect}; @@ -23,7 +24,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), @@ -129,6 +130,36 @@ fn method_param(p: &Value) -> Parsed> { } } +/// The `spread` param of `p`, if given: what the gradient paints past the ends of its stops. +fn spread_param(p: &Value) -> Parsed> { + match p.get("spread") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().and_then(GradientSpread::parse).map(Some).ok_or_else(|| "`spread` must be pad, repeat or reflect".into()), + } +} + +/// The `repeats` param of `p`, if given: how many times the stops run along the vector with +/// Repeat and Reflect, a whole number from 1 to [`MAX_REPEATS`]. +fn repeats_param(p: &Value) -> Parsed> { + match p.get("repeats") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|n| n.fract() == 0.0 && (1.0..=f64::from(MAX_REPEATS)).contains(n)) + .map(|n| Some(n as u32)) + .ok_or_else(|| format!("`repeats` must be a whole number from 1 to {MAX_REPEATS}")), + } +} + +/// An error when `p` gives a spread other than pad for a gradient of `kind` that has no ends to +/// spread past (freeform). +fn spread_for(kind: GradientKind, p: &Value) -> Parsed<()> { + match spread_param(p)? { + Some(s) if !s.is_pad() && kind == GradientKind::Freeform => Err("`spread` applies to linear, radial, conic and diamond gradients".into()), + _ => Ok(()), + } +} + /// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d` /// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and /// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps @@ -271,6 +302,9 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { Some(v) => v.as_bool().ok_or("`dither` must be true or false")?, }; gp.gradient.method = method_param(g)?.unwrap_or_default(); + spread_for(kind, g)?; + gp.gradient.spread = spread_param(g)?.unwrap_or_default(); + gp.gradient.repeats = repeats_param(g)?.unwrap_or(1); gp.angle = f64_or(g, "angle", 0.0); gp.swatch = str_param(g, "swatch").map(str::to_string); let geom = GeomEdit::parse(g)?; @@ -422,6 +456,13 @@ fn apply_gradient_edit_in_model( if let Some(m) = method_param(p)? { gp.gradient.method = m; } + spread_for(gp.gradient.kind, p)?; + if let Some(sp) = spread_param(p)? { + gp.gradient.spread = sp; + } + if let Some(n) = repeats_param(p)? { + gp.gradient.repeats = n; + } // Angle and aspect edits keep the focal point in its place in the extent ellipse. let before = gp.geom; if let Some(a) = p.get("angle").and_then(Value::as_f64) { @@ -516,6 +557,18 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { } _ => std::borrow::Cow::Borrowed(p), }; + // A spread for selected gradients leaves a freeform default (which has no ends) as it is. + let spreads = matches!(spread_param(p), Ok(Some(sp)) if !sp.is_pad()); + let for_default = match for_default { + q if spreads && !ids.is_empty() && default_kind == Some(GradientKind::Freeform) => { + let mut q = q.into_owned(); + if let Some(o) = q.as_object_mut() { + o.remove("spread"); + } + std::borrow::Cow::Owned(q) + } + q => q, + }; let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model) .map_err(|e| bad(C, e))?; let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform); diff --git a/crates/engine/src/cmd/gradpresets.rs b/crates/engine/src/cmd/gradpresets.rs index d27809fa78..29de2210ae 100644 --- a/crates/engine/src/cmd/gradpresets.rs +++ b/crates/engine/src/cmd/gradpresets.rs @@ -4,7 +4,7 @@ //! The built-in presets are the gradient swatches of the built-in swatch libraries //! ([`vectorcraft_color::libraries`]: the app's own computed palettes), each listed under its //! library's name. Saved presets are kept with the preferences ([`crate::Prefs::gradient_presets`]), -//! so every document has them. A preset is the colour ramp (type, stops, method, dither), not a +//! so every document has them. A preset is the color ramp (type, stops, method, dither, spread), not a //! placement: applying one edits the gradient in place, as `paint.editGradient` does. use serde::{Deserialize, Serialize}; @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient Presets", [], None, - "{} → {presets: [{name, library: the built-in library (theme) it comes from, null for a saved one, builtIn, gradient: {kind, stops, method?, dither?} (as paint.setFill's `gradient`)}]} the Gradient panel's presets: the built-in ones by library, then the saved ones; names are unique", + "{} → {presets: [{name, library: the built-in library (theme) it comes from, null for a saved one, builtIn, gradient: {kind, stops, method?, dither?, spread?, repeats?} (as paint.setFill's `gradient`)}]} the Gradient panel's presets: the built-in ones by library, then the saved ones; names are unique", always, list ), @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Apply Gradient Preset", [], None, - "{name: a preset's name (gradient.presets.list), stroke?, item?, ids?|id?, target? (as paint.editGradient)} give the gradient behind the active proxy (or the targeted fill or stroke) the preset's type, stops and method, and a fill its dither, keeping its placement; a solid or no paint becomes the gradient. One undo step, as paint.editGradient → {name, ids: the objects painted (none: only the default changed)}", + "{name: a preset's name (gradient.presets.list), stroke?, item?, ids?|id?, target? (as paint.editGradient)} give the gradient behind the active proxy (or the targeted fill or stroke) the preset's type, stops, method, spread and repeats (a freeform preset's: the first three), and a fill its dither, keeping its placement; a solid or no paint becomes the gradient. One undo step, as paint.editGradient → {name, ids: the objects painted (none: only the default changed)}", has_doc, apply ), @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "Save Gradient Preset", [], None, - "{name?: (default: a new \"Gradient Preset N\"; unique, any case, and not a built-in preset's)} save the gradient behind the active proxy (paint.toggleActive picks Fill or Stroke) as a preset, kept with the preferences: its type, stops, method and dither (stops linked to a swatch keep their colour, unlinked) → {name}", + "{name?: (default: a new \"Gradient Preset N\"; unique, any case, and not a built-in preset's)} save the gradient behind the active proxy (paint.toggleActive picks Fill or Stroke) as a preset, kept with the preferences: its type, stops, method, dither, spread and repeats (stops linked to a swatch keep their colour, unlinked) → {name}", can_save, save ), @@ -90,12 +90,13 @@ fn builtin_presets() -> impl Iterator { }) } -/// Do `a` and `b` have the same colour ramp (type, method, dither and stops), swatch links aside? +/// Do `a` and `b` have the same color ramp (type, method, dither, spread as it paints and stops), +/// swatch links aside? fn same_ramp(a: &Gradient, b: &Gradient) -> bool { let stop = |x: &vectorcraft_color::GradientStop, y: &vectorcraft_color::GradientStop| { (x.offset, x.color, x.opacity, x.midpoint) == (y.offset, y.color, y.opacity, y.midpoint) }; - (a.kind, a.method, a.dither) == (b.kind, b.method, b.dither) + (a.kind, a.method, a.dither, a.painted_spread()) == (b.kind, b.method, b.dither, b.painted_spread()) && a.stops.len() == b.stops.len() && a.stops.iter().zip(&b.stops).all(|(x, y)| stop(x, y)) } @@ -215,6 +216,11 @@ fn apply(s: &mut Session, p: &Value) -> Result { o.insert("kind".into(), json!(g.kind.label().to_lowercase())); o.insert("stops".into(), stops_json(&g.stops)); o.insert("method".into(), json!(g.method.label().to_lowercase())); + // A freeform preset takes no spread (paint.editGradient refuses one). + if g.kind != vectorcraft_color::GradientKind::Freeform { + o.insert("spread".into(), json!(g.spread.label().to_lowercase())); + o.insert("repeats".into(), json!(g.repeat_count())); + } // Dither is a fill's (paint.editGradient refuses it on a stroke). if !stroke { o.insert("dither".into(), json!(g.dither)); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 593d325460..43d9a8e05f 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "New Artboard", ["Window", "Artboards"], None, - "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", + "{x?, y?, width?, height? (each more than 0 and at most 3,999,856 pt, as file.new takes them), name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", has_doc, artboard_new ), @@ -926,6 +926,9 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { let guide_label = artboard_guide_label(p, "artboard.new")?.flatten(); check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?; check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?; + if ["width", "height"].iter().filter_map(|k| p.get(*k).and_then(Value::as_f64)).any(|side| !super::newdoc::side_in_range(side)) { + return Err(super::newdoc::side_error("artboard.new", d0.units)); + } let r = match artboard_side(p, "artboard.new")? { Some(side) => { if ["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { @@ -941,6 +944,10 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { 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/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/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 { "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/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 { + if FOLDER_KEYS.contains(&key) { + Some(Location::Folder) + } else if FILE_KEYS.contains(&key) { + Some(Location::File) + } else { + None + } +} + +/// What `key` names on disk in command `spec`: [`key_location`], or `None` when [`NOT_FILES`] +/// names it for `spec` (it names part of the document there). +pub fn location_in(spec: &CommandSpec, key: &str) -> Option { + if NOT_FILES.contains(&(spec.id, key)) { None } else { key_location(key) } +} + +/// The param keys of `spec` that name a file or a folder, with what they name: the keys in +/// [`FILE_KEYS`] that its params description names ([`super::params::doc_keys`]), with those of the +/// commands its `…` entries name ([`super::params::takes_key`]), and its schema's params, less those +/// [`NOT_FILES`] names for it. Sorted, each key once. +pub fn location_keys(spec: &CommandSpec) -> Vec<(&'static str, Location)> { + let described = super::params::doc_keys(spec.params).map(|p| p.keys).unwrap_or_default(); + let in_schema = spec.schema.map(|s| s.params.iter().map(|p| p.name)).into_iter().flatten(); + let taken = FILE_KEYS.iter().copied().filter(|k| super::params::takes_key(spec.params, k)); + let mut keys: Vec<(&'static str, Location)> = + described.into_iter().chain(in_schema).chain(taken).filter_map(|k| Some((k, location_in(spec, k)?))).collect(); + keys.sort_unstable_by_key(|(k, _)| *k); + keys.dedup(); + keys +} /// Commands and the param key in [`FILE_KEYS`] that, in them, names part of the document (a path of /// its art, a setting, an attribute it stores or a result field), not a file. @@ -119,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::>()).unwrap_or_default(); + for k in described.into_iter().chain(in_schema).filter(|k| FILE_KEYS.contains(k) && !NOT_FILES.contains(&(spec.id, *k))) { + assert!(found.iter().any(|(f, _)| *f == k), "{}: `{k}` is missing from {found:?}", spec.id); + } + assert!(found.iter().all(|(k, l)| (*l == Location::Folder) == FOLDER_KEYS.contains(k)), "{}: {found:?}", spec.id); + assert!(denied(spec).is_some() || found.is_empty(), "{} names {found:?} but a plug-in may call it", spec.id); + } + assert_eq!(key_location("url"), Some(Location::File)); + assert_eq!(key_location("sourceFolder"), Some(Location::Folder)); + assert_eq!(key_location("pathData"), None); + } + #[test] fn keys_are_found_as_whole_keys() { assert!(names_key("{path: a .wasm file}", "path")); diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 060791972f..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", diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index c809d58090..751313957f 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -393,11 +393,12 @@ fn float_key(v: f32) -> Option { /// keys. type StopKey<'a> = (u32, ColorKey, u32, u32, Option<&'a str>, u32, Option<[u32; 4]>); -/// A gradient's kind, method, dither and stops, as keys. -type GradientKey<'a> = (u8, u8, bool, Vec>); +/// A gradient's kind, method, dither, spread and count as it paints them, and stops, as keys. +type GradientKey<'a> = (u8, u8, bool, (vectorcraft_color::GradientSpread, u32), Vec>); -/// A key that is equal for two gradients exactly when the gradients are equal (`==`); `None` when -/// any of its numbers is NaN, so the gradient equals no gradient. +/// A key that is equal for two gradients exactly when the gradients are equal (`==`), apart from +/// a count that a gradient which pads does not use ([`Gradient::painted_spread`]); `None` when any +/// of its numbers is NaN, so the gradient equals no gradient. fn gradient_key(g: &Gradient) -> Option> { let kind = match g.kind { GradientKind::Linear => 0, @@ -410,7 +411,7 @@ fn gradient_key(g: &Gradient) -> Option> { GradientMethod::Classic => 0, GradientMethod::Perceptual => 1, }; - Some((kind, method, g.dither, g.stops.iter().map(stop_key).collect::>()?)) + Some((kind, method, g.dither, g.painted_spread(), g.stops.iter().map(stop_key).collect::>()?)) } /// A stop's key for [`gradient_key`]; `None` when any of its numbers is NaN. diff --git a/crates/engine/src/cmd/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 { "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 { "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>, font_axes: Option, font_height_reference: Option, + /// Take away the glyph choice ([`CharStyle::glyph_alternate`]) of the characters (`glyphAlternate: null`). + clear_glyph_alternate: bool, } /// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None` @@ -567,7 +569,8 @@ pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) { } } -/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). +/// `features: ["dlig", "-liga", "salt=2", …]` → the canonical tag list (differences from the +/// defaults; `salt=1` is written `salt`, `salt=0` and `-salt` leave it out). pub(crate) fn features_param(p: &Value, cmd: &str) -> Result>> { let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) }; let not_tags = || bad(cmd, "features must be a list of tags"); @@ -576,7 +579,8 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> return Err(bad( cmd, format!( - "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; prefix - to turn off)" + "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {}, as `salt=2`)", + vectorcraft_text::MAX_FEATURE_VALUE ), )); } @@ -586,9 +590,9 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> /// The most entries a `featureChanges` list may have. const MAX_FEATURE_CHANGES: usize = 256; -/// A feature tag without its `-` or `+` prefix. +/// A feature tag without its `-` or `+` prefix and its `=N` value. fn feature_name(tag: &str) -> &str { - tag.strip_prefix(['-', '+']).unwrap_or(tag) + vectorcraft_text::OtFeatures::tag_name(tag) } /// `featureChanges: ["tnum", "-onum", …]` → the features to turn on (or, with `-`, off) on top of @@ -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 { }; 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 { 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 { 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 0328667d82..e71267a2de 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -138,7 +138,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", …] OpenType, featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, 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 ), diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 9038598659..617d9d944c 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Free Distort", [], None, - "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles", + "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays", has_doc, distort ), @@ -104,7 +104,8 @@ impl Projective { } /// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine -/// approximation at their centre. +/// approximation at their center. The art of a linked opacity mask is warped with the object it +/// masks; an unlinked one stays where it is. fn warp_node(n: &mut Node, pr: &Projective) { n.pin_gradients(); match &mut n.kind { @@ -131,6 +132,9 @@ fn warp_node(n: &mut Node, pr: &Projective) { return; } } + if let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) { + warp_node(Arc::make_mut(&mut m.art), pr); + } n.appearance.warp_gradients(&|p| pr.affine_near(p)); } diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 08b8a2f294..0ef7555e02 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -34,6 +34,9 @@ pub struct DocState { /// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape /// ([`cmd::globaledit`]). Editing state: not saved, not undoable. pub global_edit: Option, + /// The objects Start Global Edit would take, highlighted before it starts + /// ([`cmd::globaledit::similar`]), as of a revision. Not saved, not undoable. + pub(crate) similar_outlines: cmd::globaledit::SimilarCache, /// What leaving symbol editing mode goes back to ([`cmd::symboledit`]). pub(crate) symbol_edit_return: cmd::symboledit::Return, pub interaction: Option, @@ -302,6 +305,7 @@ impl DocState { variables_highlight: None, isolation: None, global_edit: None, + similar_outlines: Default::default(), symbol_edit_return: Default::default(), interaction: None, undo_group: None, diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index 435d918552..a0193a27f8 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -631,6 +631,32 @@ fn an_opacity_mask_on_type_bends_as_other_art() { assert_eq!(kept, 1, "the type's stroke stays a stroke"); } +/// An envelope bends the art of a linked opacity mask on its contents with them; an unlinked mask +/// stays where it is. +#[test] +fn an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one() { + for linked in [true, false] { + let mut s = session(); + let a = rect(&mut s); + let m = rect(&mut s); + sel(&mut s, &[a, m]); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + if !linked { + s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap(); + } + let before = node(&s, a).extras().mask.as_ref().unwrap().art.path_data().cloned(); + sel(&mut s, &[a]); + let e = arch(&mut s, 60.0, json!({})); + let mut found = vec![]; + for n in &evaluated(&s, e) { + n.walk(&mut |c| found.extend(c.extras().mask.as_ref().map(|k| (c.path_data().cloned(), k.art.path_data().cloned())))); + } + let [(bent, mask)] = found.as_slice() else { panic!("one masked object: {found:?}") }; + assert!(bent != &before, "the contents bend"); + assert_eq!(mask, if linked { bent } else { &before }, "linked: {linked}"); + } +} + /// Expand turns an envelope of stroked type into its bent glyph outlines with the stroke on them, /// at its own weight, as it draws (#6644). #[test] diff --git a/crates/engine/src/tests/globaledit.rs b/crates/engine/src/tests/globaledit.rs index 60bf81a060..ac5a494dc7 100644 --- a/crates/engine/src/tests/globaledit.rs +++ b/crates/engine/src/tests/globaledit.rs @@ -92,7 +92,50 @@ fn start_global_edit_takes_the_objects_of_the_same_shape_at_any_place_and_size() assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2); let r = s.execute("select.globalEdit", &json!({})).unwrap(); assert_eq!(r["active"], false, "the second run is Stop Global Edit"); - assert!(s.doc().unwrap().global_edit.is_none() && crate::cmd::globaledit::highlights(&s).is_empty()); + assert!(s.doc().unwrap().global_edit.is_none()); + assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2, "with the square still selected, the objects it would take are highlighted again"); +} + +#[test] +fn selecting_one_object_highlights_the_similar_objects_before_global_edit_starts() { + use crate::cmd::globaledit::highlights; + let mut s = session(); + let [a, b, _, wide, _] = squares(&mut s); + assert!(highlights(&s).is_empty(), "nothing selected"); + sel(&mut s, &[a]); + assert_eq!(members(&s), None, "Global Edit has not started"); + assert_eq!(highlights(&s).len(), 2, "the copy and the smaller square"); + s.execute("select.globalEditOptions", &json!({"size": true})).unwrap(); + assert_eq!(highlights(&s).len(), 1, "the options narrow them: the copy only"); + s.execute("select.globalEditOptions", &json!({"size": false})).unwrap(); + assert_eq!(highlights(&s).len(), 2); + sel(&mut s, &[b]); + s.execute("edit.clear", &json!({})).unwrap(); + sel(&mut s, &[a]); + assert_eq!(highlights(&s).len(), 1, "a deleted copy is no longer highlighted"); + sel(&mut s, &[wide]); + assert!(highlights(&s).is_empty(), "the rectangle has no similar object"); + sel(&mut s, &[a, wide]); + assert!(highlights(&s).is_empty(), "two objects selected"); +} + +#[test] +fn the_canvas_highlights_similar_objects_before_the_start_with_the_selection_tool_only() { + let mut s = session(); + let [a, b, ..] = squares(&mut s); + let outline = node(&s, b).stroke_path().unwrap(); + let shown = |s: &mut Session| { + s.overlays(ViewInfo::default()) + .iter() + .any(|o| matches!(o, vectorcraft_tools::Overlay::Path { path, width, .. } if *path == outline && *width == 1.0)) + }; + sel(&mut s, &[a]); + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(shown(&mut s), "the copy is highlighted with the Selection tool"); + s.select_tool("directSelection", ViewInfo::default()).unwrap(); + assert!(!shown(&mut s), "not with another tool before Global Edit starts"); + s.execute("select.globalEdit", &json!({})).unwrap(); + assert!(shown(&mut s), "while Global Edit is on, with any tool"); } #[test] @@ -506,6 +549,7 @@ fn leaving_out_all_but_one_ends_global_edit_and_the_first_object_stays() { let r = s.execute("select.globalEditExclude", &json!({"id": b.0})).unwrap(); assert_eq!(r, json!({"active": false, "count": 0, "ids": []})); assert_eq!(members(&s), None, "one object left: Global Edit ends"); + sel(&mut s, &[wide]); assert!(crate::cmd::globaledit::highlights(&s).is_empty()); } diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs index 73e0172da5..a2aea6341d 100644 --- a/crates/engine/src/tests/gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -881,3 +881,134 @@ fn making_a_gradient_diamond_is_one_undo_step() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(fill_gradient(&s, id), before); } + +/// `paint.setFill` and `paint.editGradient` take a spread and a count for each kind of gradient +/// that has ends; the params give them back, the native format keeps them, and a change is one +/// undo step while an edit that changes nothing records none. +#[test] +fn a_gradient_takes_a_spread_and_a_count() { + use vectorcraft_color::GradientSpread; + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + for kind in ["linear", "radial", "conic", "diamond"] { + s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "spread": "Reflect", "repeats": 4}})).unwrap(); + let g = fill_gradient(&s, id); + assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Reflect, 4), "{kind}"); + let params = vectorcraft_tools::params::gradient_params(&g); + assert_eq!((params["spread"].as_str(), params["repeats"].as_u64()), (Some("reflect"), Some(4)), "{kind}"); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.spread, GradientSpread::Pad, "a new gradient pads"); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient, g.gradient, "{kind}"); + } + let steps = s.doc().unwrap().history.undo.len(); + s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 2})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one step"); + let g = fill_gradient(&s, id); + assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Repeat, 2)); + s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 2})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "nothing changed: no step"); + s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.repeats, 2, "Pad keeps the count for later"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fill_gradient(&s, id), g); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("the gradient is lost") }; + assert_eq!((saved.gradient.spread, saved.gradient.repeats), (GradientSpread::Repeat, 2)); + // The params of a gradient that pads give back the count it keeps. + s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap(); + let params = vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id)); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); + assert_eq!((fill_gradient(&s, id).gradient.spread, fill_gradient(&s, id).gradient.repeats), (GradientSpread::Pad, 2), "{params}"); +} + +/// A spread or count a gradient can't take is an error, from every command that sets one, and the +/// gradient stays as it was: an unknown spread, a count that is not a whole number from 1 to 100, +/// and a spread other than pad on a freeform gradient. +#[test] +fn a_bad_spread_or_count_is_refused() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "spread": "repeat", "repeats": 3}})).unwrap(); + let before = fill_gradient(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + for bad in [json!({"spread": "mirror"}), json!({"spread": 1}), json!({"spread": true})] { + let e = s.execute("paint.editGradient", &bad).unwrap_err().to_string(); + assert!(e.contains("`spread` must be pad, repeat or reflect"), "{bad}: {e}"); + } + for n in [json!(0), json!(-2), json!(1.5), json!(101), json!(1e9), json!("3"), json!(f64::MAX)] { + let e = s.execute("paint.editGradient", &json!({ "repeats": n })).unwrap_err().to_string(); + assert!(e.contains("`repeats` must be a whole number from 1 to 100"), "{n}: {e}"); + let e = s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "repeats": n}})).unwrap_err().to_string(); + assert!(e.contains("`repeats`"), "{n}: {e}"); + } + let freeform = json!({"kind": "freeform", "spread": "reflect"}); + let e = s.execute("paint.editGradient", &freeform).unwrap_err().to_string(); + assert!(e.contains("`spread` applies to linear, radial, conic and diamond gradients"), "{e}"); + let e = s.execute("paint.setFill", &json!({ "gradient": freeform })).unwrap_err().to_string(); + assert!(e.contains("`spread` applies to"), "{e}"); + assert_eq!(fill_gradient(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // Pad on a freeform gradient is allowed (it has nothing to spread), and 100 is the most. + s.execute("paint.editGradient", &json!({"repeats": 100})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "freeform", "spread": "pad"})).unwrap(); + // A repeating gradient turned freeform: its params, which carry no spread, set it again. + s.execute("paint.editGradient", &json!({"kind": "radial", "spread": "reflect"})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap(); + let params = vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id)); + assert!(params.get("spread").is_none(), "{params}"); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); +} + +/// A spread for a selected gradient applies while the gradient used last, the default, is a +/// freeform one, which keeps no spread. +#[test] +fn a_spread_for_a_selected_gradient_leaves_a_freeform_default_alone() { + use vectorcraft_color::GradientSpread; + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + rect(&mut s, 200.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "freeform"}})).unwrap(); + s.execute("paint.editGradient", &json!({"ids": [a.0], "spread": "repeat", "repeats": 3})).unwrap(); + let g = fill_gradient(&s, a); + assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Repeat, 3)); +} + +/// A preset keeps its spread and count: saved from a gradient, applied to another object. +#[test] +fn a_gradient_preset_keeps_its_spread() { + use vectorcraft_color::GradientSpread; + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "spread": "reflect", "repeats": 5}})).unwrap(); + s.execute("gradient.presets.save", &json!({"name": "Fan"})).unwrap(); + let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("gradient.presets.apply", &json!({"name": "Fan"})).unwrap(); + let g = fill_gradient(&s, b); + assert_eq!((g.gradient.kind, g.gradient.spread, g.gradient.repeats), (GradientKind::Conic, GradientSpread::Reflect, 5)); + assert_eq!(fill_gradient(&s, a).gradient.repeats, 5); +} + +/// A gradient set back to Pad keeps its count for later, and still matches a saved preset and a +/// swatch of the same gradient; while it repeats, the spread tells them apart. +#[test] +fn a_padding_gradient_matches_whatever_count_it_keeps() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let ramp = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + s.execute("paint.setFill", &json!({ "gradient": ramp })).unwrap(); + s.execute("gradient.presets.save", &json!({"name": "Plain"})).unwrap(); + s.execute("swatch.new", &json!({"name": "Plain", "paint": {"gradient": ramp}})).unwrap(); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().contains(&json!("Plain")); + assert!(!unused(&mut s)); + s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 5})).unwrap(); + assert_eq!(s.active_saved_gradient_preset(), None, "a repeating gradient is another ramp"); + assert!(unused(&mut s), "nothing paints the swatch's gradient"); + s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap(); + assert_eq!(fill_gradient(&s, a).gradient.repeats, 5); + assert_eq!(s.active_saved_gradient_preset(), Some("Plain")); + assert!(!unused(&mut s), "the gradient paints as the swatch does"); +} diff --git a/crates/engine/src/tests/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/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/plugins.rs b/crates/engine/src/tests/plugins.rs index 76df1c2194..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"); 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/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/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index ac98db8e9c..4542496fc5 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4330,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`]. @@ -4442,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 1ab878fc62..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()), ] } @@ -6911,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] @@ -7258,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 @@ -7676,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 c28c538bcf..29eb2bf910 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -40,8 +40,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { } /// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]), -/// by id: paths and compound paths with points and groups, with live effects or linked to a graphic -/// style, without Opacity & Mask Define Knockout Shape (the reader takes an object's transparency +/// by id: paths and compound paths with points and groups, with live effects, linked to a graphic +/// style or (paths and compound paths) with a brush on a stroke, without Opacity & Mask Define +/// Knockout Shape (the reader takes an object's transparency /// from its look, which doesn't carry it). A group's members are paths, compound paths or such /// groups, none with effects or anything baking changes. pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { @@ -76,7 +77,8 @@ fn bakes_members(n: &Node) -> bool { /// Whether `n` may be written with an art style ([`styled_objects`]). fn candidate(n: &Node) -> bool { - let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some(); + let brushed = brushed(n) && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some() || brushed; if !styled || n.knockout_shape { return false; } @@ -88,6 +90,12 @@ fn candidate(n: &Node) -> bool { } } +/// Does `n` have a stroke with a brush that paints, one that its art style holds ([`Writer::style_text`] +/// leaves out what paints nothing)? +fn brushed(n: &Node) -> bool { + n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && !s.paint.is_none())) +} + /// Does `path` write anything ([`Writer::plain_object`] skips subpaths without points)? fn has_points(path: &vectorcraft_geom::PathData) -> bool { path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) @@ -144,7 +152,7 @@ impl Styles { } /// One style as the reader's section holds it: `/KnownStyle :`, `(name) /Name ,`, its text. -fn known_style(name: &str, text: &str) -> String { +pub(super) fn known_style(name: &str, text: &str) -> String { format!("/KnownStyle :\n{} /Name ,\n{text}", ps::bytes(name.as_bytes())) } @@ -172,7 +180,7 @@ const NO_LINE: &str = "0 J 0 j 1 w 10 M []0 d\n"; /// A basic filter's start: its name and kind numbers (`1 0` for an effect, `0 0` for the /// style's own filters), `/Visible` and the dictionary's start. -fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { +pub(super) fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { let _ = write!(s, "/BasicFilter :\n{} {} 0 /Filter ,\n{} /Visible ,\n", ps::bytes(name.as_bytes()), u8::from(effect), u8::from(visible)); } @@ -180,7 +188,7 @@ fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { const CONDUIT: &str = "/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n"; /// A basic filter's end. -const END_BASIC: &str = "; /Dict ;\n /Part ,\n"; +pub(super) const END_BASIC: &str = "; /Dict ;\n /Part ,\n"; /// The blend filter of transparency `xy`. fn blend_filter(s: &mut String, xy: Xy) { @@ -228,7 +236,7 @@ fn flag(p: &Value, key: &str) -> Option { impl Writer<'_> { /// What `paint` writes with [`Self::color`] (a color, a spot or global color), as a string. - fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String { + pub(super) fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String { let saved = std::mem::take(&mut self.body); self.color(paint, stroke); std::mem::replace(&mut self.body, saved) @@ -267,9 +275,12 @@ impl Writer<'_> { } /// A stroke's basic filter: a color, its line style, and its width profile as the arrays - /// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`. `None` for - /// other paint. + /// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`; a stroke with a + /// brush, the brush's filter ([`Self::brush_filter`]). `None` for other paint. fn stroke_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> { + if st.brush.is_some() { + return self.brush_filter(s, st, visible); + } if !matches!(st.paint, Paint::Solid { .. }) || st.overprint { return None; } @@ -652,17 +663,19 @@ impl Writer<'_> { && let Some(text) = self.styles.known.iter().find(|(n, _)| *n == name).map(|(_, t)| t.clone()) && object_reads(orig, &text) { - self.styles.links_written += 1; return Some(name); } - if orig.appearance.effects.is_empty() || !own_style_holds(&orig.appearance) { + let brushed = brushed(orig); + if (orig.appearance.effects.is_empty() && !brushed) || !own_style_holds(&orig.appearance) { return None; } let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_))); let saved = (graded.then(|| self.gradient_mark()), self.warnings.len()); let path = drawn_outline(n, orig); let rule = object_rule(orig); - let text = self.style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))).filter(|t| object_reads(orig, t)); + let text = self + .style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))) + .filter(|t| object_reads(orig, t) && (!brushed || self.brushed_style_reads(t))); let Some(text) = text else { if let Some(mark) = saved.0 { self.gradient_rollback(mark); @@ -693,7 +706,8 @@ impl Writer<'_> { /// compound path's points painted with nothing, or with its gradient fill when its style has /// one; a group with its members), its name and opacity mask; then `1 (name) XW`. /// The look's group and the object itself are counted first ([`Self::take_objects`]): without - /// room for them, nothing is written. + /// room for them, nothing is written, and the object counts neither as written with its graphic + /// style nor as written with a brush on its stroke. pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) { let own = match &orig.kind { NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), @@ -704,6 +718,12 @@ impl Writer<'_> { if !self.take_objects(1 + own) { return; } + let linked = orig.extras().graphic_style.and_then(|id| self.styles.panel.get(&id)).is_some_and(|p| p == name); + if linked { + self.styles.links_written += 1; + } else if brushed(orig) { + self.brush_set.live_written += 1; + } self.state(orig.locked, !orig.visible); let xy = Xy::of(orig); let was = self.in_look; @@ -834,8 +854,12 @@ fn own_style_holds(ap: &Appearance) -> bool { ap.items.iter().filter(|i| !i.paint().is_none()).all(plain) && !ap.effects.iter().any(panel_only) } -/// Does the reader read graphic style `g` from style text `text` as it is? +/// Does the reader read graphic style `g` from style text `text` as it is? It leaves out a graphic +/// style with a brush on a stroke that paints. fn panel_reads(g: &GraphicStyle, text: &str) -> bool { + if g.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && !s.paint.is_none())) { + return false; + } let section = known_style("x", text); let Some(read) = crate::import::read_art_style(section.as_bytes(), "x", None) else { return false }; // The opacity within the written number's precision. @@ -918,7 +942,7 @@ fn normalized_params(e: &Effect) -> Value { const NEAR: f64 = 6e-3; /// JSON values equal but for numbers within the written numbers' precision. -fn near(a: &Value, b: &Value) -> bool { +pub(super) fn near(a: &Value, b: &Value) -> bool { match (a, b) { (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { (Some(x), Some(y)) => (x - y).abs() <= NEAR, diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index dc335bf1a1..b750efbc20 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -86,8 +86,8 @@ pub use ai::NameEncoding; #[cfg(test)] pub(crate) use ai::objects_read; pub(crate) use ai::{ - MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, - symbol_art_member, warp_number, + BRUSH_MANAGER, BrushesRead, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, + read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number, }; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index be2d679a78..2d6c4a533d 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -34,6 +34,7 @@ use std::io::Read as _; use super::lex::find; use crate::ps; +pub(crate) use brush::{BrushesRead, MANAGER_NAME as BRUSH_MANAGER, brushes_kept, plugin_name as brush_plugin_name, read_brush_object}; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; #[cfg(test)] pub(crate) use envelope::MAX_CHECKED; diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index dc94ff7927..5a5db47114 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -563,3 +563,127 @@ pub(super) fn without_frame(art: Node) -> Node { } out } + +/// The plug-in name the objects of brushes of kind `kind` have, for writers. +pub(crate) fn plugin_name(kind: &BrushKind) -> &'static str { + let tool = match kind { + BrushKind::Calligraphic(_) => Tool::Calligraphic, + BrushKind::Scatter(_) => Tool::Scatter, + BrushKind::Art(_) => Tool::Art, + BrushKind::Pattern(_) => Tool::Pattern, + BrushKind::Bristle(_) => Tool::Bristle, + }; + TOOLS.iter().find(|(_, t)| *t == tool).map_or("", |(n, _)| *n) +} + +/// The plug-in name of the brush manager's order, for writers. +pub(crate) const MANAGER_NAME: &str = MANAGER; + +/// A brush object as the reader reads it, for writers that check a brush before they write it. +#[derive(Clone, Debug)] +pub(crate) struct BrushRead { + /// The brush the Brushes panel gets. + pub brush: Brush, + /// A path whose art style names the brush, at the brush's own settings, opens with the brush on + /// its stroke: a Calligraphic brush, or an Art brush whose art an Art brush draws as the file + /// does ([`art_bends`]) and whose numbers are those of a brush that stretches its art along the + /// path, unflipped, at 100% in Tints. + pub live: bool, +} + +/// The brush named `name` whose object has the plug-in name `plugin` and the parameters `params`, +/// with `art` as the art its parameters name (as the reader reads that art), as the reader reads +/// it: `None` when the reader doesn't make a brush of it. +pub(crate) fn read_brush_object(plugin: &str, name: &str, params: &str, art: Option<&Node>) -> Option { + let draft = parse_tool_object(tool(plugin)?, name, params).ok()?; + let mut sections = HashMap::new(); + if let (DraftKind::Scatter { art: section, .. } | DraftKind::Art { art: section, .. }, Some(a)) = (&draft.kind, art) { + sections.insert(section.clone(), a.clone()); + } + let drafts = [draft]; + let live = kept(&drafts, §ions).first().copied().unwrap_or(false) + && match &drafts[0].kind { + DraftKind::Calligraphic(_) => true, + DraftKind::Art { plain: true, art: section, .. } => bending_art(&drafts, §ions).contains(section), + _ => false, + }; + let [draft] = drafts; + let (mut brushes, _) = finish_brushes(vec![draft], §ions, None); + Some(BrushRead { brush: brushes.pop()?, live }) +} + +/// Which of the brush objects `brushes` (plug-in name, name, parameters, the art each names as the +/// reader reads it) the reader makes into brushes when one file holds them all: not one it can't read, +/// nor one past the art the reader copies into one file's brushes ([`kept`]). +pub(crate) fn brushes_kept(brushes: &[(&str, &str, &str, Option<&Node>)]) -> Vec { + let mut drafts = vec![]; + let mut at = vec![]; + let mut sections = HashMap::new(); + for (i, (plugin, name, params, art)) in brushes.iter().enumerate() { + let Some(d) = tool(plugin).and_then(|t| parse_tool_object(t, name, params).ok()) else { continue }; + if let (DraftKind::Scatter { art: s, .. } | DraftKind::Art { art: s, .. }, Some(a)) = (&d.kind, art) { + sections.insert(s.clone(), (*a).clone()); + } + drafts.push(d); + at.push(i); + } + let mut out = vec![false; brushes.len()]; + for (i, k) in at.into_iter().zip(kept(&drafts, §ions)) { + if let Some(slot) = out.get_mut(i) { + *slot = k; + } + } + out +} + +/// Brush objects a writer writes, as the reader reads them together from one file, for checking +/// the art styles that name them ([`Self::style_reads`]). +pub(crate) struct BrushesRead { + drafts: Vec, + /// The brush a stroke that names a brush is drawn with, by name: the first one kept + /// ([`kept`]), as an index in [`Self::drafts`]. + by_name: HashMap, + /// The art an Art brush draws as the file does ([`bending_art`]). + bending: HashSet, +} + +impl BrushesRead { + /// `brushes`: the brush objects written (plug-in name, name, parameters) and the art each names, + /// as the reader reads it. + pub(crate) fn new(brushes: &[(&str, &str, &str, Option<&Node>)]) -> Self { + let mut drafts = vec![]; + let mut sections = HashMap::new(); + for (plugin, name, params, art) in brushes { + let Some(d) = tool(plugin).and_then(|t| parse_tool_object(t, name, params).ok()) else { continue }; + if let (DraftKind::Scatter { art: s, .. } | DraftKind::Art { art: s, .. }, Some(a)) = (&d.kind, art) { + sections.insert(s.clone(), (*a).clone()); + } + drafts.push(d); + } + let mut by_name = HashMap::new(); + for (i, (d, k)) in drafts.iter().zip(kept(&drafts, §ions)).enumerate() { + if k { + by_name.entry(d.name.clone()).or_insert(i); + } + } + let bending = bending_art(&drafts, §ions); + Self { drafts, by_name, bending } + } + + /// Does the reader read every stroke of art style `style` in `section` (the text between + /// `%AI9_BeginArtStyles` and its end) that names a brush with that brush on it, at the brush's + /// own settings? A style the reader doesn't read, or that names a brush no object gives, or an + /// Art brush in a style with effects, isn't read so. + pub(crate) fn style_reads(&self, section: &[u8], style: &str) -> bool { + use super::style::StyleItem; + let styles = super::style::art_styles(section); + let Some(st) = styles.get(style).filter(|st| st.plain) else { return false }; + st.items.iter().all(|item| match item { + StyleItem::Stroke { brush: Some((name, settings)), .. } => { + let Some(draft) = self.by_name.get(name).and_then(|i| self.drafts.get(*i)) else { return false }; + draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art) && !st.effects.is_empty()) + } + _ => true, + }) + } +} diff --git a/crates/eps/src/import/ai/read/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/cid.rs b/crates/eps/src/import/cid.rs index a54c11ae2b..4c00486de9 100644 --- a/crates/eps/src/import/cid.rs +++ b/crates/eps/src/import/cid.rs @@ -18,7 +18,6 @@ use vectorcraft_geom::BezPath; use super::interp::{Interp, matrix_obj}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; -use super::predefined_cmap::Unicode; /// Most codespace ranges, character and notdef mappings a CMap may hold. const MAX_RANGES: usize = 1 << 16; @@ -211,32 +210,6 @@ fn number(data: &[u8], at: usize, n: usize) -> Option { (n <= 8).then(|| bytes.iter().fold(0u64, |v, b| v << 8 | u64::from(*b))) } -/// The encoding of predefined Unicode CMap `name` and whether it is vertical: one of Adobe's -/// public CMap resources whose name says its codes are Unicode: `Uni` and a character -/// collection, then `-UCS2`, `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), -/// then `-H` or `-V` (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: -/// ). -pub(super) fn unicode_cmap(name: &str) -> Option<(Unicode, bool)> { - let (rest, dir) = name.rsplit_once('-')?; - let vertical = match dir { - "H" => false, - "V" => true, - _ => return None, - }; - let rest = rest.strip_suffix("-HW").unwrap_or(rest); - let (collection, form) = rest.rsplit_once('-')?; - if !(collection.len() > 3 && collection.starts_with("Uni") && !collection.contains('-')) { - return None; - } - let form = match form { - "UCS2" | "UTF16" => Unicode::Utf16, - "UTF8" => Unicode::Utf8, - "UTF32" => Unicode::Utf32, - _ => return None, - }; - Some((form, vertical)) -} - /// A predefined Identity CMap: two-byte codes, each the CID of its own value; vertical for /// `Identity-V`. pub(super) fn identity(name: &str, vertical: bool) -> Obj { @@ -396,11 +369,9 @@ impl Interp<'_> { return Ok(d.clone()); } let text = name.text().map(|t| t.to_string()).unwrap_or_default(); - // Another predefined CMap the file doesn't define, without its mappings: its codes are - // read by its name when type is set (see `text`). - let predefined = unicode_cmap(&text) - .map(|(_, vertical)| vertical) - .or_else(|| super::predefined_cmap::known(&text).then(|| super::predefined_cmap::vertical(&text))); + // A predefined CMap the file doesn't define, without its mappings: its codes are read by + // its name when type is set (see `text`). + let predefined = super::predefined_cmap::known(&text).then(|| super::predefined_cmap::vertical(&text)); match predefined { Some(vertical) => { let mut d = Dict::new(); diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 58708bb97e..6b96aabc25 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -95,6 +95,9 @@ pub(crate) struct Interp<'a> { /// The predefined CMaps type was set in so far, by name, with how their strings decode /// (`None` when one can't be read; see `text`). pub predefined_cmaps: Vec<(Rc, Option>)>, + /// The font a `kshow` procedure sees as current while type in a CIDFont only named is set in + /// a font of its own (see `text`). + pub kshow_font: Option, pub g: GState, pub saved: Vec, pub out: Out, @@ -236,6 +239,7 @@ impl<'a> Interp<'a> { 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 index 1a9b3b95be..19525fdf36 100644 --- a/crates/eps/src/import/predefined_cmap.rs +++ b/crates/eps/src/import/predefined_cmap.rs @@ -157,7 +157,7 @@ pub(super) enum Unicode { /// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`, /// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V` /// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: ). -fn unicode_cmap(name: &str) -> Option { +pub(super) fn unicode_cmap(name: &str) -> Option { let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?; let rest = rest.strip_suffix("-HW").unwrap_or(rest); let (collection, form) = rest.rsplit_once('-')?; @@ -325,6 +325,28 @@ pub(super) fn known(cmap: &str) -> bool { unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() } +/// The codes of string `bytes` in predefined CMap `cmap` when its mappings can't be read, each +/// standing for no character: as long as the code space range it falls in ([`code_space`]; a byte +/// that starts no code is a code of its own), 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") diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 9e452c4bf4..25ed81c2b0 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -426,8 +426,6 @@ fn named_cidfonts_over_predefined_cmaps_set_their_characters_as_type() { ("83pv-RKSJ-H", "93fa967b41", "日本A"), ("90ms-RKSJ-H", "93fa", "日"), ("EUC-H", "c6fc", "日"), - // JIS row and cell: 日 is 0x467C. - ("H", "467c", "日"), ("GBK-EUC-H", "d6d0", "中"), ("B5pc-H", "a4a4", "中"), ("ETen-B5-H", "a4a4", "中"), @@ -448,6 +446,9 @@ fn named_cidfonts_over_predefined_cmaps_set_their_characters_as_type() { } let r = super::tests::read("/X /GBK-EUC-H [/NotInFile] composefont 20 scalefont setfont 10 20 moveto show"); assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::(), "中A"); + // JIS row and cell: 日 is 0x467C. + let r = super::tests::read("/X /H [/Ryumin-Light] composefont 20 scalefont setfont 10 20 moveto <467c> show"); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::(), "日"); // The point moves past the type, along the line. check("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont <93fa967b> stringwidth abs 0.01 lt exch dup 5 gt exch 60 lt and and"); } @@ -478,8 +479,34 @@ fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() { measured(&format!( "{gb} 10 20 moveto <56505650> show currentpoint pop 10 20 moveto 7 0 16#5650 <56505650> widthshow currentpoint pop sub -14 sub abs 0.01 lt" )); - // `xshow` moves 30 a code. + // `awidthshow` adds 5 to each code and 7 more to the space. + measured(&format!( + "{set} 10 20 moveto <93fa20967b> show currentpoint pop 10 20 moveto 7 0 32 5 0 <93fa20967b> awidthshow currentpoint pop sub -22 sub abs 0.01 lt" + )); + // `xshow` moves 30 a code, `yshow` 10 up a code, `xyshow` both. measured(&format!("{set} 10 20 moveto <93fa967b> [30 30] xshow currentpoint pop 70 sub abs 0.01 lt")); + measured(&format!("{set} 10 20 moveto <93fa967b> [10 10] yshow currentpoint 40 sub abs 0.01 lt exch 10 sub abs 0.01 lt and")); + measured(&format!("{set} 10 20 moveto <93fa967b> [30 10 30 10] xyshow currentpoint 40 sub abs 0.01 lt exch 70 sub abs 0.01 lt and")); + // `kshow` runs its procedure between each two codes: it moves the point 100 more here, 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!( @@ -530,10 +557,15 @@ fn named_cid_type_is_set_alike_by_every_show_operator() { let r = super::tests::read(&format!("{set} <00660069> {show}")); let t = texts(&r); assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["fi"], "{show}"); - t[0].runs.iter().map(|r| r.style.features.clone()).collect::>() + // Spacing that differs from the advances splits the type into runs of the same features. + let mut features = t[0].runs.iter().map(|r| r.style.features.clone()).collect::>(); + features.dedup(); + features }; let plain = features("show"); - assert_eq!(plain, features("0 0 3 -1 roll ashow")); + for show in ["0 0 3 -1 roll ashow", "0 0 32 4 -1 roll widthshow", "0 0 32 0 0 6 -1 roll awidthshow", "[25 25] xshow", "{pop pop} exch kshow"] { + assert_eq!(plain, features(show), "{show}"); + } assert!(plain.iter().all(|f| f.iter().any(|f| f == "-liga")), "{plain:?}"); check(&format!( "gsave nulldevice {set} <00660069> stringwidth 10 20 moveto 0 0 <00660069> ashow currentpoint grestore \ @@ -541,17 +573,77 @@ fn named_cid_type_is_set_alike_by_every_show_operator() { )); } -/// A font name ending in the row-and-cell CMap `H` after a name without a hyphen stays a font of -/// the whole name, its strings read byte by byte. Type in a predefined CMap whose mappings aren't -/// available is left out with a warning, and a long string in a CID-keyed font over a predefined -/// CMap shown in a loop runs out of the budget. +/// `charpath`, `cshow` and `glyphshow` in a CID-keyed font over a predefined CMap, its CIDFont only +/// named: `charpath` adds the outlines of the characters the codes stand for and moves the point +/// as `show` does; `cshow`'s procedure gets each code (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() { - let r = super::tests::read("/Futura-H findfont 20 scalefont setfont 10 20 moveto (AB) show"); - assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["AB"]); + for (font, family, style) in [("Futura-H", "Futura", "H"), ("Foo-Bold-H", "Foo", "Bold-H"), ("Foo-Condensed-V", "Foo", "Condensed-V")] { + let r = super::tests::read(&format!("/{font} findfont 20 scalefont setfont 10 20 moveto (AB) show")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["AB"], "{font}"); + let s = &t[0].runs[0].style; + assert_eq!((s.font_family.as_str(), s.font_style.as_str()), (family, style), "{font}"); + assert!(!t[0].vertical, "{font}"); + } + // The file defines the CIDFont `Ryumin-Light`, as a resource or as a font: `Ryumin-Light-H` is + // it over the CMap `H`. + // A CIDFont name without a hyphen too, when the file defines it. + let cid_font = |name: &str| format!("/{name} << /CIDFontName /{name} /CIDFontType 0 /FontType 9 /FontMatrix [0.001 0 0 0.001 0 0] >>"); + let defines = [ + format!("{} /CIDFont defineresource pop /Ryumin-Light-H", cid_font("Ryumin-Light")), + format!("{} definefont pop /Ryumin-Light-H", cid_font("Ryumin-Light")), + format!("{} /CIDFont defineresource pop /Foo-H", cid_font("Foo")), + ]; + for define in defines { + let r = super::tests::read(&format!("{define} findfont 20 scalefont setfont 10 20 moveto <467c> show")); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["日"], "{define}: {:?}", r.warnings); + } let r = super::tests::read("/Ryumin-Light-GBT-H findfont 20 scalefont setfont 10 20 moveto (AB) show 0 0 10 10 rectfill"); assert!(texts(&r).is_empty(), "{:?}", all(&r.document)); assert_eq!(super::tests::program_warnings(&r.warnings), [super::text::UNREAD_CMAP], "{:?}", r.warnings); + // Two two-byte codes: two ems along the line, or down the column (a square drawn when the + // point moved that far). + for cond in [ + "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <21212121> stringwidth abs 0.01 lt exch 40 sub abs 0.01 lt and", + "/Ryumin-Light-GBT-V findfont 20 scalefont setfont 10 100 moveto <21212121> show currentpoint 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and", + // A tab is a code of its own: three codes. + "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <09092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", + ] { + let r = super::tests::read(&format!("{cond} {{ 0 0 10 10 rectfill }} if")); + assert!(all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "{cond}"); + } super::tests::runs_out_of_budget("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont { 100000 string stringwidth pop pop } loop"); } diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index ee9b0fc759..73fc4776d0 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -171,8 +171,8 @@ impl Interp<'_> { ResourceForAll => self.resource_for_all()?, Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => { let (s, spacing) = self.show_operands(op)?; - if let Some((codes, font, vertical)) = self.named_cid_type(&s)? { - self.show_named_cid(&codes, &font, vertical, &spacing)?; + if let Some(named) = self.named_cid_type(&s)? { + self.show_named_cid(&named, &spacing, Draw::Fill)?; } else if let Some(glyphs) = self.composite_glyphs(&s)? { self.show_composite(&glyphs, &spacing)?; } else { @@ -186,6 +186,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)))?; @@ -208,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())?; + } } } } @@ -283,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((codes, font, vertical)) = self.named_cid_type(s)? { - let saved = self.g.font.replace(font); - let r = self.named_cid_width(&codes, vertical); - self.g.font = saved; - return r; + if let Some(named) = self.named_cid_type(s)? { + return self.named_cid_width(&named, &named.codes); } if let Some(glyphs) = self.composite_glyphs(s)? { let mut w = Vec2::ZERO; @@ -399,25 +420,23 @@ impl Interp<'_> { /// is a predefined CMap, the CID-keyed font of those two (the usual name of a CID-keyed font, /// as `composefont` makes them: PLRM 3rd ed., chapter 8; the CMaps are Adobe's public CMap /// resources, ); else a font of that name. - /// A name an installed face has is that face's, and a name split at a bare `H` or `V` (the - /// row-and-cell CMaps) needs a CIDFont name with a hyphen in it (`Ryumin-Light-H`), so - /// `Futura-H` stays a font of that name. + /// A name that ends in a predefined CMap other than a Unicode CMap and that an installed face + /// has is that face's ([`Self::predefined_split`]). fn find_font(&mut self, k: &Obj) -> Res { let key = k.key().ok_or(PsError::Ps("typecheck", "findfont".into()))?; if let Some(Obj::Dict(d)) = self.fonts.borrow().get(&key) { return Ok(d.clone()); } let text = k.text().map(|t| t.to_string()).unwrap_or_default(); - let split = text + let unicode = text .match_indices('-') .map(|(i, _)| i) - .find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()) - .and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) - .or_else(|| { - let (cid_font, cmap) = super::predefined_cmap::composite_font(&text)?; - let installed = vectorcraft_text::FontDb::global().find_postscript(&text).is_some(); - (!installed && !(matches!(cmap, "H" | "V") && !cid_font.contains('-'))).then_some((cid_font, cmap)) - }); + .find(|&i| i > 0 && super::predefined_cmap::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()) + .and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))); + let split = match unicode { + Some(split) => Some(split), + None => self.predefined_split(&text), + }; if let Some((cid_font, cmap)) = split { let cmap = self.find_cmap(&Obj::name(cmap))?; let dep = self.find_cid_font(&Obj::name(cid_font))?; @@ -434,6 +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() { @@ -575,15 +620,25 @@ impl Interp<'_> { codes.push(u32::from(code), c.filter(|c| !c.is_control())); } let widths: Option> = plain.map(|(_, glyphs)| glyphs.iter().map(|g| g.width).collect()); - self.show_codes(&codes, widths.as_deref(), spacing, false, s) + self.show_codes(&codes, widths.as_deref(), spacing, false, s, Draw::Fill) } /// The codes `codes` (each standing for some of its characters, or none) shown in the current /// font as [`Self::show_plain`] shows a string's, spaced as `spacing` says, each code `widths` /// wide when given (in glyph space, one unit an em), else as wide as its characters' advances; - /// as vertical type when `vertical`, the codes moving the point down the column. `kern` is the - /// string `kshow` gives its procedure the codes of. - fn show_codes(&mut self, codes: &Decoded, widths: Option<&[f64]>, spacing: &Spacing, vertical: bool, kern: &[u8]) -> Res { + /// as vertical type when `vertical`, the codes moving the point down the column. `kern` holds + /// the codes `kshow` gives its procedure, one for each code. With `draw` [`Draw::Path`] the + /// type's glyph outlines are added to the current path, placed as the type would be + /// (`charpath`). + fn show_codes>( + &mut self, + codes: &Decoded, + widths: Option<&[f64]>, + spacing: &Spacing, + vertical: bool, + kern: &[C], + draw: Draw, + ) -> Res { let at = self.user_point()?; let (_, f1) = self.font()?; let text = &codes.text; @@ -627,8 +682,9 @@ impl Interp<'_> { return ps_err("undefinedresult", "a show"); } let across = moves.iter().any(|m| m.2.abs() > size * 1e-3); - // Nothing shows on the null device (`nulldevice`, a Type 3 glyph only measured). - let painted = !self.g.paint.is_none() && !self.g.null; + // Nothing shows on the null device (`nulldevice`, a Type 3 glyph only measured); + // `charpath` adds the outlines there too. + let painted = draw == Draw::Path || (!self.g.paint.is_none() && !self.g.null); if across { // Moves across the baseline (`yshow`, `xyshow`): each character where the file puts it. let mut pen = at; @@ -637,8 +693,12 @@ impl Interp<'_> { let c: String = chars.by_ref().take(n).collect(); if painted && !c.trim().is_empty() { let (t, ..) = self.set_text_in(&c, pen, vertical)?; - let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); + if draw == Draw::Path { + self.type_path(&t)?; + } else { + let clips = self.g.clips.clone(); + self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); + } } pen += *d; } @@ -686,27 +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) } @@ -757,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()?; @@ -791,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(()) @@ -855,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 { @@ -986,9 +1091,9 @@ impl Interp<'_> { /// of that name and its character collection's CID → Unicode CMap (a code it doesn't map /// stands for none), set as type in the font of its CIDFont's name, vertical over a vertical /// CMap (`WMode` 1). Control codes stand for no character. Type in a CMap VectorCraft can't - /// read is left out, with a warning. - fn named_cid_type(&mut self, s: &[u8]) -> Res> { - let Some((cmap, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) }; + /// read is left out, with a warning, each of its codes moving the point one em. + 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 @@ -996,25 +1101,33 @@ impl Interp<'_> { let held = s.len().saturating_mul(DECODED_BYTES); self.alloc(held)?; self.unalloc(held); - let decoded = match self.predefined_codes(&cmap) { - Some(codes) => codes.codes(s), + let (decoded, read) = match self.predefined_codes(&cmap) { + Some(codes) => (codes.codes(s), true), None => { self.out.warn(UNREAD_CMAP); - Decoded::default() + (super::predefined_cmap::unread_codes(&cmap, s), false) } }; - let mut codes = Decoded::default(); + let Some(mut named) = self.named_cid_font()? else { return Ok(None) }; let mut chars = decoded.text.chars(); for &(code, n) in &decoded.codes { - codes.push(code, chars.by_ref().take(n).filter(|c| !c.is_control())); + named.codes.push(code, chars.by_ref().take(n).filter(|c| !c.is_control())); } + named.ems |= !read; + Ok(Some(named)) + } + + /// The font [`Self::named_cid_type`] sets type in, with no codes yet, when the current font is + /// a CID-keyed font over a predefined CMap the file doesn't define. + fn named_cid_font(&mut self) -> Res> { + let Some((_, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) }; // The composite font is copied under the CIDFont's name for every string: charged by // its size, so a huge font dictionary shown in a loop runs out of the budget. let entries = font.borrow().len(); self.spend(entries as u64)?; let mut named = font.borrow().clone(); named.insert(Key::name("FontName"), name); - Ok(Some((codes, DictRef::new(named), vertical))) + Ok(Some(NamedCid { codes: Decoded::default(), font: DictRef::new(named), vertical, ems: vertical })) } /// The CMap's name, the composite font, the CIDFont's name and the writing mode of @@ -1032,7 +1145,7 @@ impl Interp<'_> { return None; } let cmap_name = cmap.get(&Key::name("CMapName")).and_then(Obj::text)?.to_string(); - if super::cid::unicode_cmap(&cmap_name).is_none() && !super::predefined_cmap::known(&cmap_name) { + if !super::predefined_cmap::known(&cmap_name) { return None; } let wmode = f.get(&Key::name("WMode")).or_else(|| cmap.get(&Key::name("WMode"))).and_then(Obj::as_num); @@ -1047,7 +1160,7 @@ impl Interp<'_> { /// How strings in predefined CMap `cmap` decode (a CMap other than a Unicode CMap read once a /// file); `None` when VectorCraft can't read it. fn predefined_codes(&mut self, cmap: &str) -> Option> { - if let Some((form, _)) = super::cid::unicode_cmap(cmap) { + if let Some(form) = super::predefined_cmap::unicode_cmap(cmap) { return Some(Rc::new(super::predefined_cmap::CMapCodes::Unicode(form))); } if let Some((_, codes)) = self.predefined_cmaps.iter().find(|(n, _)| **n == *cmap) { @@ -1060,32 +1173,53 @@ impl Interp<'_> { codes } - /// The codes `codes` (with their characters) set as point type at the current point in font - /// `font` (see [`Self::named_cid_type`]), running down a column when `vertical`, which moves - /// past them, spaced as `spacing` says, as [`Self::show_codes`] shows them. `widthshow` and - /// `awidthshow` match a code by its value (font number 0 times 256 plus the code: PLRM 3rd ed., - /// `widthshow` in chapter 8); `kshow`'s procedure isn't run between them (a composite font's - /// codes aren't single bytes). Down a column each code moves the point one em, the default - /// vertical displacement of a CIDFont whose metrics the file doesn't give (PDF Reference 1.7, - /// §5.6.3, `DW2`). - fn show_named_cid(&mut self, codes: &Decoded, font: &DictRef, vertical: bool, spacing: &Spacing) -> Res { - let widths = vertical.then(|| vec![1.0; codes.codes.len()]); - let saved = self.g.font.replace(font.clone()); - let r = self.show_codes(codes, widths.as_deref(), spacing, vertical, &[]); + /// Type `named` (see [`Self::named_cid_type`]) set as point type at the current point in its + /// font, running down a column when it is vertical, which moves past it, spaced as `spacing` + /// says, as [`Self::show_codes`] shows codes. `widthshow` and `awidthshow` match a code by its + /// value (font number 0 times 256 plus the code: PLRM 3rd ed., `widthshow` in chapter 8), and + /// `kshow`'s procedure gets the values of the two codes it runs between. Down a column, and in + /// a CMap VectorCraft can't read, each code moves the point one em, the default displacement + /// of a CIDFont whose metrics the file doesn't give (PDF Reference 1.7, §5.6.3, `DW` and + /// `DW2`). With `draw` [`Draw::Path`] the outlines go on the current path (`charpath`). + fn show_named_cid(&mut self, named: &NamedCid, spacing: &Spacing, draw: Draw) -> Res { + let widths = named.ems.then(|| vec![1.0; named.codes.codes.len()]); + let kern: Vec = match spacing { + Spacing::Kern(_) => named.codes.codes.iter().map(|&(code, _)| code).collect(), + _ => Vec::new(), + }; + let saved = self.g.font.replace(named.font.clone()); + let before = std::mem::replace(&mut self.kshow_font, saved.clone()); + let r = self.show_codes(&named.codes, widths.as_deref(), spacing, named.vertical, &kern, draw); + self.kshow_font = before; + // 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 } - /// How far the codes `codes` move the current point (user space) when [`Self::show_named_cid`] - /// shows them with no extra spacing. - fn named_cid_width(&mut self, codes: &Decoded, vertical: bool) -> Res { + /// [`Self::named_cid_width`] in the current font: codes `codes`, vertical when `vertical`, + /// each one em when `ems`. + fn named_cid_moved(&mut self, codes: &Decoded, vertical: bool, ems: bool) -> Res { let (_, f1) = self.font()?; - let moved = if vertical { - Point::new(0.0, -(codes.codes.len() as f64)) - } else { - let (t, layout) = self.set_placed_text(&codes.text, Point::ZERO, false)?; - let size = t.runs.first().map_or(1.0, |r| r.style.size); - Point::new(layout.glyphs.iter().map(|g| g.advance).sum::() / size, 0.0) + let n = codes.codes.len() as f64; + let moved = match (vertical, ems) { + (true, _) => Point::new(0.0, -n), + (false, true) => Point::new(n, 0.0), + (false, false) => { + let (t, layout) = self.set_placed_text(&codes.text, Point::ZERO, false)?; + let size = t.runs.first().map_or(1.0, |r| r.style.size); + Point::new(layout.glyphs.iter().map(|g| g.advance).sum::() / size, 0.0) + } }; Ok(f1 * moved - f1 * Point::ZERO) } @@ -1393,7 +1527,7 @@ const MISSING_CID_FONT: &str = "CID-keyed type in a font the file doesn't carry const DECODED_BYTES: usize = 64; /// The warning for type in a predefined CMap whose mappings VectorCraft doesn't have. -pub(super) const UNREAD_CMAP: &str = "Type in a CMap that couldn't be read was left out"; +pub(super) const UNREAD_CMAP: &str = "type in a CMap that couldn't be read was left out"; /// Most Type 42 fonts whose TrueType data is kept between glyphs. const MAX_TYPE42: usize = 16; diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 2a09cf003c..fe7085b522 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -113,6 +113,8 @@ const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the const FREEFORM: &str = "freeform gradients are written as their average colour"; const CONIC: &str = "conic gradients are written as solid wedges clipped to the shape (legacy .ai files have no conic gradient)"; const DIAMOND: &str = "diamond gradients are written as four linear gradients clipped to the shape (legacy .ai files have no diamond gradient)"; +const SPREAD: &str = "gradients that repeat or reflect are written with their stops repeated across the shape (legacy .ai gradients only pad)"; +const SPREAD_CAPPED: &str = "gradients that repeat or reflect are written with at most 256 stops; they pad past those"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGES: usize = 360; const STROKES: &str = 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/tests.rs b/crates/eps/src/legacy_ai/tests.rs index d189c94ed9..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 { diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 032317ab7a..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, @@ -487,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; @@ -501,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); } @@ -678,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 { 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/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 4f3ddeb86d..054cfec267 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -404,7 +404,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "view.actualSize", "Actual Size", "Cmd+1", - "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", + "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling); the same at every UI Scaling", ), ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), ( @@ -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/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/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/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/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/paint.rs b/crates/render/src/paint.rs index dd83e4cee7..1d39ece10c 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -19,7 +19,15 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr } Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => crate::freeform::set_freeform_paint(ctx, g, bounds, f.ink), Paint::Gradient(g) => { - let geom = g.resolve(bounds); + // A gradient that repeats or reflects: one run of its stops over its period, which + // the renderer extends past both ends. + let spread = g.gradient.is_spread(); + let geom = if spread { g.resolve(bounds).period(g.gradient.kind, g.gradient.repeat_count()) } else { g.resolve(bounds) }; + let extend = match g.gradient.spread { + _ if !spread => peniko::Extend::Pad, + vectorcraft_color::GradientSpread::Reflect => peniko::Extend::Reflect, + _ => peniko::Extend::Repeat, + }; let adjusted = f.ink.gradient(&g.gradient); let gradient = adjusted.as_ref().unwrap_or(&g.gradient); let ink = if adjusted.is_some() { f.ink.base() } else { f.ink }; @@ -42,9 +50,11 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr } GradientKind::Conic => { // One turn about the origin, carried onto the extent ellipse: its frame starts - // the sweep along the vector, and a mirrored frame reverses it. + // the sweep along the vector, and a mirrored frame reverses it. Repeated `n` + // times, one run takes 1/n of the turn. ctx.set_paint_transform(geom.conic_frame()); - peniko::Gradient::new_sweep((0.0, 0.0), 0.0, std::f32::consts::TAU).with_stops(stops.as_slice()) + let turn = if spread { std::f32::consts::TAU / g.gradient.repeat_count() as f32 } else { std::f32::consts::TAU }; + peniko::Gradient::new_sweep((0.0, 0.0), 0.0, turn).with_stops(stops.as_slice()) } // peniko has no diamond gradient: it is drawn as pixels, as a dithered fill is. GradientKind::Diamond => return crate::dither::set_dithered_paint(ctx, g, bounds, bounds, f.ink, false), @@ -57,7 +67,7 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr }; // Colour and opacity blend each on its own, as the model samples them and as the PDF // and SVG exports draw them (peniko's default premultiplies). - ctx.set_paint(grad.with_interpolation_alpha_space(peniko::InterpolationAlphaSpace::Unpremultiplied)); + ctx.set_paint(grad.with_extend(extend).with_interpolation_alpha_space(peniko::InterpolationAlphaSpace::Unpremultiplied)); true } Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, f), diff --git a/crates/render/src/pattern/tests.rs b/crates/render/src/pattern/tests.rs index 76aad7fe6f..701afe9b41 100644 --- a/crates/render/src/pattern/tests.rs +++ b/crates/render/src/pattern/tests.rs @@ -251,19 +251,30 @@ fn kept_tiles_stay_within_the_budget() { #[test] fn kept_tiles_count_the_images_they_could_not_decode() { use vectorcraft_doc::{ImageBlob, ImageObject}; - let mut d = doc_with_pattern(TileType::Grid); - // A PNG signature with nothing after it. - d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; - d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; - let mut r = Renderer::new(); - let start = tiles_rasterized(); - for n in 0..3 { - let before = crate::paint::decode_failures(); - frame(&mut r, &d, 1.0); - assert!(crate::paint::decode_failures() > before, "frame {n}"); + // A tile of art with an image is drawn again when the fonts change, and other tests add + // fonts, so the frames come from a run in which the fonts stayed the same. Each run draws a + // new document, whose first frame finds no tile kept from an earlier run. + let _fonts = crate::tests_fontchange::fonts_steady(); + let (failures, rasterized) = crate::tests_fontchange::with_fonts_unchanged(|| { + let mut d = doc_with_pattern(TileType::Grid); + // A PNG signature with nothing after it. + d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); + let im = + ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; + let mut r = Renderer::new(); + let start = tiles_rasterized(); + let failures = [0, 1, 2].map(|_| { + let before = crate::paint::decode_failures(); + frame(&mut r, &d, 1.0); + crate::paint::decode_failures() > before + }); + (failures, tiles_rasterized() - start) + }); + for (n, failed) in failures.into_iter().enumerate() { + assert!(failed, "frame {n}"); } - assert_eq!(tiles_rasterized() - start, 1, "the later frames draw from the tile kept"); + assert_eq!(rasterized, 1, "the later frames draw from the tile kept"); } #[test] diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs index 2bf0d165db..1d61a2656c 100644 --- a/crates/render/src/renderer/paint.rs +++ b/crates/render/src/renderer/paint.rs @@ -212,11 +212,12 @@ impl Renderer { let tol = 2f64.powi(level); // Reach a pixel past the stroke, so its antialiased edge has colour under it. let slices = effects::stroke::gradient_slices(bp, rule, st, tol, f.px + tol, f.px); + let unrolled = g.unrolled(); Arc::new( slices .into_iter() .map(|s| { - let (b, paint) = (s.shape.bounding_box(), s.paint(g)); + let (b, paint) = (s.shape.bounding_box(), s.paint(&unrolled)); (s.shape, paint, b) }) .collect(), diff --git a/crates/render/src/tests/strokegradient.rs b/crates/render/src/tests/strokegradient.rs index 961f18590f..f286a04252 100644 --- a/crates/render/src/tests/strokegradient.rs +++ b/crates/render/src/tests/strokegradient.rs @@ -47,6 +47,18 @@ fn along_a_quarter_of_the_length_is_a_quarter_of_the_gradient_in_any_direction() } } +/// A gradient that reflects twice runs its stops twice along the stroke, the second run +/// backwards. +#[test] +fn along_a_reflecting_gradient_runs_its_stops_that_many_times() { + let g = Gradient { spread: vectorcraft_color::GradientSpread::Reflect, repeats: 2, ..Gradient::default() }; + let img = render(&line((20.0, 100.0), (180.0, 100.0)), g, StrokeGradientMode::Along, true); + for (f, want) in [(0.125, 0.75), (0.375, 0.25), (0.625, 0.25), (0.875, 0.75)] { + let x = 20.0 + 160.0 * f; + assert!((grey(&img, x, 100.0) - want).abs() < 0.03, "at {f}: {} vs {want}", grey(&img, x, 100.0)); + } +} + #[test] fn across_runs_from_the_left_edge_to_the_right() { // Travelling right, the left edge is the top one (y 90), the right edge the bottom (y 110). diff --git a/crates/render/tests/it/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/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 47fcfbaf14..4ac3530b19 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -49,7 +49,6 @@ mod depth; mod files; mod fx; mod linear_rgb; -mod spread; mod symbol_units; mod text; mod vector_effect; 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/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, 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 { - 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 { - 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 { - 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 = 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 = 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 = 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/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 `` 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 `` 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. //! * `` 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/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/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##""## + ); + 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/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/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/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 32e1df2a16..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, 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/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/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 069b0df087..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:?}"); } 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 ef6a6d20c8..68729b861c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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/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/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4825b5ea1d..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: Разпространение: @@ -4247,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 повреден @@ -6684,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 89622f761c..0e7dd028fd 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1678,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} @@ -1836,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: @@ -3910,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 @@ -4606,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 @@ -4635,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 @@ -6736,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 0b642a2007..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 @@ -4314,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… @@ -5106,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 @@ -5309,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 @@ -6031,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 % @@ -6984,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 b9599f2f6d..6a74590c93 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1707,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} @@ -1867,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: @@ -3966,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 @@ -4687,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 @@ -4716,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 @@ -6089,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. @@ -7003,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 1256ed94ef..2b7d6edd5a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1613,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} @@ -1694,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: @@ -3685,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 @@ -4359,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 @@ -5515,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 @@ -6698,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 17392851a3..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 @@ -3388,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: @@ -4302,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 @@ -4331,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 @@ -5680,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. @@ -6598,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 48099c9440..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: Λειτουργία: @@ -3842,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) @@ -4362,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 κατεστραμμένο @@ -5844,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 @@ -6040,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} φορές τη δουλειά που επιτρέπεται στα άλλα πρόσθετα. Εκτελείται στο παρασκήνιο και μπορείτε να την ακυρώσετε. @@ -6955,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 8bc2616f25..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 @@ -3418,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: @@ -4378,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 @@ -4407,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 @@ -6031,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. @@ -6946,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 d9bf3e80da..fc809c8928 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1680,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} @@ -1840,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: @@ -3890,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 @@ -4624,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 @@ -5856,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 @@ -6119,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. @@ -7038,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 93f330de11..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 @@ -3410,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 : @@ -4367,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 @@ -4396,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é @@ -6084,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. @@ -6998,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 d2fc0dc733..902a6b5978 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1428,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 डाउनलोड @@ -2767,6 +2778,8 @@ antiAlias Sharp शार्प Meters मीटर Method विधि Method: विधि: + Pad विस्तार करें + Repeats दोहराव Mezzotint मेज़ोटिंट Mezzotint… मेज़ोटिंट… Millimeters मिलीमीटर @@ -4676,6 +4689,9 @@ antiAlias Sharp शार्प Sponge… स्पंज… spot स्पॉट Spot Color स्पॉट रंग + Spot Color Objects स्पॉट कलर्स के साथ ऑब्जेक्ट्स + Pattern Objects पैटर्न्स के साथ ऑब्जेक्ट्स + Gradient Objects ग्रेडिएंट्स के साथ ऑब्जेक्ट्स Spot Colors स्पॉट रंग Spot Colors (0) स्पॉट कलर्स (0) Spot Colors… स्पॉट कलर्स… @@ -5885,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}°) से अधिक नहीं हो सकता है। @@ -6306,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" होना चाहिए। @@ -7923,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} है) @@ -8122,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 होना चाहिए परिमित संख्या @@ -8436,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 eadf8f2058..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 @@ -4070,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… @@ -4922,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 @@ -4958,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 @@ -6072,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. @@ -6987,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 1dc3348ae3..c6dad138e3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1613,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} @@ -1694,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: @@ -3685,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 @@ -4359,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 @@ -5515,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 @@ -6698,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 ee47282a98..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 @@ -3463,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: @@ -4429,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 @@ -4458,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 @@ -5714,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. @@ -6632,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 4d73b8aedc..0413cc8afc 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1540,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 ダブルクリックで内容をターゲット @@ -2274,6 +2285,8 @@ antiAlias Sharp シャープ Metadata: メタデータ: Meters メートル Method: 方法: + Pad パッド + Repeats リピート回数 Millimeters ミリメートル Minify 最小化 Minimize 最小化 @@ -3240,6 +3253,9 @@ mark-style Japanese 日本式 Add Guides ガイドを追加 Split Path パスを分割 Spot Color 特色 + Spot Color Objects 特色を使用したオブジェクト + Pattern Objects パターンを使用したオブジェクト + Gradient Objects グラデーションを使用したオブジェクト Spot Colors 特色 Spot Colors… 特色… Spread: 拡散: @@ -4592,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 アクティブなドキュメントがありません @@ -4784,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` を指定してください @@ -5779,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} 倍の処理を行うことがあります。バックグラウンドで実行され、キャンセルできます。 @@ -6697,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 7ac6cef429..5901488cc8 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1552,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} @@ -1633,6 +1644,8 @@ digits Malayalam მალაიალამური Delete Image Trace Preset გამოსახულების ვექტორიზაციის პრესეტის წაშლა Cancel Image Trace გამოსახულების ვექტორიზაციის გაუქმება Method: მეთოდი: + Pad გაგრძელება + Repeats გამეორებების რაოდენობა Mode: რეჟიმი: Noise ხმაური Create: შექმნა: @@ -3525,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 ფერების ჯგუფის დაშლა @@ -4178,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 დაზიანებულია @@ -5442,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-ად აწერს. @@ -6040,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}-ჯერ მეტი სამუშაო შეასრულოს. ის ფონურ რეჟიმში მუშაობს და შეგიძლიათ გააუქმოთ. @@ -7117,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 fff1a1a14a..1db3500541 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1530,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 더블클릭하여 내용 선택 @@ -2264,6 +2275,8 @@ antiAlias Sharp 날카롭게 Metadata: 메타데이터: Meters 미터 Method: 방법: + Pad 연장 + Repeats 반복 횟수 Millimeters 밀리미터 Minify 압축 Minimize 최소화 @@ -3229,6 +3242,9 @@ mark-style Japanese 일본식 Add Guides 안내선 추가 Split Path 패스 분할 Spot Color 별색 + Spot Color Objects 별색을 사용한 개체 + Pattern Objects 패턴을 사용한 개체 + Gradient Objects 그레이디언트를 사용한 개체 Spot Colors 별색 Spot Colors… 별색… Spread: 확산: @@ -4519,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 활성 문서가 없습니다 @@ -4711,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`를 지정하세요 @@ -5675,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}배를 수행할 수 있습니다. 백그라운드에서 실행되며 취소할 수 있습니다. @@ -6593,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 c03747f38a..0400283630 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1627,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} @@ -1708,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: @@ -3699,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 @@ -4373,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 @@ -5523,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 @@ -6749,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 40efff131c..6e595e8673 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1619,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} @@ -1785,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: @@ -3825,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 @@ -4510,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 @@ -4539,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 @@ -5943,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ć. @@ -6859,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 f7bda91264..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 @@ -2870,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: @@ -3947,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 @@ -5793,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 @@ -6004,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. @@ -7076,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 92a9e3c965..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 Минимальный @@ -3450,6 +3463,9 @@ digits Malayalam Малаялам Split Path Разделить контур spot плашечный Spot Color Плашечный цвет + Spot Color Objects Объекты с плашечными цветами + Pattern Objects Объекты с узорами + Gradient Objects Объекты с градиентами Spot Colors Плашечные цвета Spot Colors… Плашечные цвета… Spread: Разворот: @@ -4415,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 недопустимое сочетание клавиш @@ -4444,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 повреждено @@ -5700,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} раз больше работы, чем разрешено другим модулям. Он работает в фоне, и его можно отменить. @@ -7092,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 a2c032a5db..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 @@ -3340,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: @@ -4310,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 @@ -5847,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 @@ -6111,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. @@ -8372,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) @@ -8481,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 fbc6b9248f..14193a1445 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -239,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 2fea56d318..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 @@ -3241,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: @@ -4141,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 @@ -4172,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ı @@ -6045,6 +6064,9 @@ axis Y Axis Y ekseni 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. @@ -6959,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 4aac86567b..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 Мінімальний @@ -3456,6 +3469,9 @@ effect Points Точки Split Path Розділити контур spot плашковий Spot Color Плашковий колір + Spot Color Objects Об’єкти з плашковими кольорами + Pattern Objects Об’єкти з візерунками + Gradient Objects Об’єкти з градієнтами Spot Colors Плашкові кольори Spot Colors… Плашкові кольори… Spread: Розворот: @@ -4413,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 неприпустиме поєднання клавіш @@ -4442,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 пошкоджено @@ -5694,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} разів більше роботи, ніж дозволено іншим модулям. Він працює у фоні, і його можна скасувати. @@ -6612,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 f40f0292a2..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 @@ -3802,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 @@ -4579,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ệ @@ -4615,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 @@ -5695,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. @@ -6613,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 6602ee3916..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 最少 @@ -3283,6 +3296,9 @@ digits Malayalam 马拉雅拉姆数字 Split Path 分割路径 spot 专色 Spot Color 专色 + Spot Color Objects 使用专色的对象 + Pattern Objects 使用图案的对象 + Gradient Objects 使用渐变的对象 Spot Colors 专色 Spot Colors… 专色… Spread: 扩散: @@ -4296,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 没有活动文档 @@ -4481,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` @@ -5927,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} 倍。它在后台运行,可以随时取消。 @@ -6841,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 545a75bcad..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 最少 @@ -4426,6 +4442,9 @@ digits Malayalam 馬來亞拉姆數字 Split Path 分割路徑 spot 特別色 Spot Color 特別色 + Spot Color Objects 使用特別色的物件 + Pattern Objects 使用圖樣的物件 + Gradient Objects 使用漸層的物件 Spot Colors 特別色 Spot Colors… 特別色… Spread: 擴散: @@ -5732,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} 倍。它會在背景執行,您可以隨時取消。 @@ -6647,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/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/lib.rs b/crates/ui-egui/src/lib.rs index 1c64655f73..b5964fe274 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index b05da18e12..e65ea938a6 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -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/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/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 2a1ffdb5d2..676a9a48c8 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -160,9 +160,10 @@ pub struct Services { pub open_in_photocraft: Option, /// Show a folder picker; returns its path (desktop: Relink to Folder, Package). pub pick_folder: Option Option>>, - /// Show the system's color sampler, which picks a color anywhere on the screen (macOS, and - /// browsers with an EyeDropper): Sample Color from Screen ([`screen_color`]). Without it that - /// command answers with an error and the Color Picker's eyedropper button is gray. + /// Show the system's color sampler, which picks a color anywhere on the screen (macOS, Linux + /// through the desktop portal, and browsers with an EyeDropper): Sample Color from Screen + /// ([`screen_color`]). Without it that command answers with an error and the Color Picker's + /// eyedropper button is gray. pub screen_color: Option, /// Where Data Recovery keeps its copies when not in a folder (the web's browser storage; /// tests): installed into the session ([`vectorcraft_engine::cmd::recovery`]). diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 7ada99ee5c..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/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 0e1e794b8d..689a0e3a04 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -1145,6 +1145,234 @@ fn a_frame_with_turns_on_and_off_a_number_field_scrolls_the_panel() { assert_eq!(value.get(), 10.0); } +/// The rest of a notch that a number field took, which egui spreads over the next frames, scrolls +/// no panel after the pointer leaves the field: neither the field's own panel nor a panel drawn +/// before it. A notch turned after that scrolls the panel under the pointer by one whole notch. +#[test] +fn the_rest_of_a_notch_a_number_field_took_scrolls_no_panel_after_the_pointer_leaves() { + for to_other_panel in [false, true] { + let mut f = Field::new(); + let (left, right, value, field) = (Cell::new(0.0), Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + ui.horizontal_top(|ui| { + let out = egui::ScrollArea::vertical().id_salt("left").max_height(120.0).show(ui, |ui| { + ui.allocate_space(vec2(100.0, 600.0)); + }); + left.set(out.state.clamped_offset().y); + let out = egui::ScrollArea::vertical().id_salt("right").max_height(120.0).show(ui, |ui| { + ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + ui.add_space(600.0); + v + }) + .inner + }); + right.set(out.state.clamped_offset().y); + out.inner + }) + .inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = if to_other_panel { Pos2::new(50.0, 60.0) } else { Pos2::new(on.x, field.get().bottom() + 40.0) }; + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + f.frame(vec![Event::PointerMoved(off)], &draw); + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert_eq!((left.get(), right.get(), value.get()), (0.0, 0.0, 9.0), "pointer moved to the other panel: {to_other_panel}"); + assert_eq!(f.frame(vec![notch_down()], &draw), None); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + let (scrolled, still) = if to_other_panel { (left.get(), right.get()) } else { (right.get(), left.get()) }; + assert!((scrolled - line).abs() < 0.01 && still == 0.0, "other panel {to_other_panel}: {scrolled} {still}"); + assert_eq!(value.get(), 9.0); + } +} + +/// A wheel touch that starts and never ends (some mouse wheels send no end) stops counting as a +/// touch once no wheel event comes for a while, as egui stops it, so the rest of a notch a number +/// field takes after that still scrolls no panel after the pointer leaves. +#[test] +fn a_wheel_touch_with_no_end_does_not_let_the_rest_of_a_later_notch_scroll_the_panel() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + ui.add_space(600.0); + v + }) + .inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = Pos2::new(on.x, field.get().bottom() + 40.0); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + for _ in 0..20 { + f.frame(vec![], &draw); + } + let start = Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::Vec2::ZERO, + phase: egui::TouchPhase::Start, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + f.frame(vec![start], &draw); + for _ in 0..10 { + f.frame(vec![], &draw); + } + assert_eq!(f.frame(vec![notch_down()], &draw), Some(8.0)); + f.frame(vec![Event::PointerMoved(off)], &draw); + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert_eq!((offset.get(), value.get()), (0.0, 8.0)); +} + +/// A whole wheel touch (start, turn and end) in one pass that a number field takes leaves nothing +/// for egui to let out later, so the next notch on the panel scrolls it by one whole notch. +#[test] +fn a_whole_touch_in_one_pass_on_a_number_field_leaves_the_next_panel_notch_whole() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + let v = ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + v + }); + ui.add_space(600.0); + v.inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = Pos2::new(on.x, field.get().bottom() + 40.0); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + for _ in 0..20 { + f.frame(vec![], &draw); + } + let touch = |phase| Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: if phase == egui::TouchPhase::Move { vec2(0.0, -1.0) } else { egui::Vec2::ZERO }, + phase, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + let events = vec![touch(egui::TouchPhase::Start), touch(egui::TouchPhase::Move), touch(egui::TouchPhase::End)]; + assert_eq!(f.frame(events, &draw), Some(8.0)); + f.frame(vec![Event::PointerMoved(off), notch_down()], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + assert!((offset.get() - line).abs() < 0.01, "{} {line}", offset.get()); + assert_eq!(value.get(), 8.0); +} + +/// A turn and the end of the wheel stream in one pass that a number field takes leave nothing for +/// egui to let out later, so the next notch on the panel scrolls it by one whole notch. +#[test] +fn a_turn_ended_in_its_own_pass_on_a_number_field_leaves_the_next_panel_notch_whole() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + let v = ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + v + }); + ui.add_space(600.0); + v.inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = Pos2::new(on.x, field.get().bottom() + 40.0); + let end = Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::Vec2::ZERO, + phase: egui::TouchPhase::End, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down(), end], &draw), Some(9.0)); + f.frame(vec![Event::PointerMoved(off), notch_down()], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + assert!((offset.get() - line).abs() < 0.01, "{} {line}", offset.get()); + assert_eq!(value.get(), 9.0); +} + +/// A small turn the other way on the panel while the rest of a notch a number field took is still +/// being let out scrolls the panel by the whole turn. +#[test] +fn a_turn_the_other_way_while_a_number_field_notch_runs_out_scrolls_the_panel_by_the_whole_turn() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + ui.add_space(150.0); + let v = ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + v + }); + ui.add_space(600.0); + v.inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + let above = |field: Rect| Pos2::new(field.center().x, field.top() - 30.0); + f.frame(vec![Event::PointerMoved(Pos2::new(field.get().center().x, 10.0)), notch_down(), notch_down()], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let scrolled = offset.get(); + assert!((scrolled - 2.0 * line).abs() < 0.01, "{scrolled}"); + f.frame(vec![Event::PointerMoved(field.get().center())], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(9.0)); + let up = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 10.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + f.frame(vec![Event::PointerMoved(above(field.get())), up], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + assert!((offset.get() - (scrolled - 10.0)).abs() < 0.01, "{} {scrolled}", offset.get()); + assert_eq!(value.get(), 9.0); +} + /// A number field that missed a pass (its panel was hidden) waits for the pointer to move onto it /// again before it takes the wheel, with or without another number field drawn meanwhile. #[test] diff --git a/crates/ui-egui/src/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/docs/control-protocol.md b/docs/control-protocol.md index b3f4e56bde..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 @@ -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,6 +1099,7 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Documented OpenType feature values in text commands (`"salt=2"`, the font's alternate number) and that runs differing only in features shape as one piece of text. | | 2026-10-31 | minor | Confining file access: on Windows a `:` in a name after the drive (an NTFS alternate data stream) is refused with the device names (#3604). | | 2026-10-25 | minor | Panel docking on craft-ui's layout: `ui.docking`, the floating and dock parameters, native floating windows. | | 2026-10-25 | minor | Documented that Object → Expand… is dimmed, and `ui.expandDialog` refuses, while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089). | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b130c01d80..ccd7c14271 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-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). @@ -113,7 +113,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, @@ -293,7 +303,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 | @@ -347,6 +357,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | +| 2026-11-12 | minor | PDF import (#5235): a glyph with no advance in a font the file writes horizontally (a vowel sign or anusvara set over its letter) stays on its line instead of opening as vertical type of its own. A line of type in an Indic script (U+0900 to U+0DFF) is set again after it is read and compared with the glyphs the file drew: when it would draw other glyphs or put them elsewhere (a vowel sign the file draws before its consonant, a conjunct the file doesn't name), the line keeps the file's glyphs as outlines, with a note. Indic type in a font that isn't installed never draws as the file does, so it keeps the file's glyphs too; other type in such a font still shows in the fallback font (#4568). Tests: `crates/pdf/src/tests/reshaped.rs`. Scores unchanged. | | 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). | | 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | diff --git a/docs/gaps.md b/docs/gaps.md index 9295b89423..8a71326662 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: oversized editing artboard import; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -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. @@ -385,6 +385,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. @@ -415,9 +417,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 @@ -593,7 +595,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. @@ -611,6 +613,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 @@ -655,8 +659,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi `graphicStyle.addFromLibrary`). - **Missing:** more built-in brush libraries beyond the eight shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; - Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the - start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). + Global Edit of groups holding type or images, and rotated copies as matches; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). - **Estimate:** 5–15 h. ## G3 3D and Materials @@ -860,6 +863,8 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | +| 2026-11-09 | minor | G4 review (#6084): positional variants no supported feature draws can be applied as glyph choices in both Glyphs views; unlisted glyphs still respect name ownership. OpenType style options keep only a valid last on-value when enabling a set. Existing contextual/ligature lookup and unsupported-feature gaps remain; estimate unchanged. | +| 2026-11-07 | minor | G4: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone (`salt=2`; the style commands take `tag=N`; runs that differ only in their features shape as one piece of text), so an Arabic word stays joined, and Show › Entire Font lists the unencoded glyphs (#5514). Estimate unchanged. | | 2026-11-12 | minor | G11: numeric fields keep the caret when a focus click is released more than 1 pt from its press; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326); short-drag and jitter-tap regressions in `tests/numfields.rs`; estimates unchanged. | | 2026-11-03 | minor | An imported document's own title (an SVG's root `<title>`, a PDF's XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name stays the file's name (#3626); no scored gap or estimate reassessed. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index ce9f55cf50..31a218eb6c 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -22,7 +22,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and | Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done | | Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done | | Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | yes, read from AppKit's tablet events before the window gets them (winit reports none), each move of a stroke with its own pressure, mouse event coalescing off (#852; not yet tried on a Mac) | yes (pointer force via `WM_POINTER`) | under X11 and XWayland (Preferences › Performance › Linux Display Server, #1151, #2028; Automatic uses XWayland when a pen is attached on a Wayland session, #4967), read from XInput2 raw events (#764); **no** on native Wayland, where winit reports none and the pen may not reach the window (#491) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate after a pen contact until reload) | partial: 2–4 h for Wayland (winit 0.31), plus a check on a Mac | -| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | with Windows Ink, read from the pen's `POINTER_PEN_INFO` (tilt and barrel rotation where its pen mask says the pen reports them); Calligraphic and Scatter brushes vary with them (#372; not yet tried with a pen) | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: native Wayland and the web read none, and Art, Pattern and Bristle brushes do not use them; 3–6 h (#372) | +| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | with Windows Ink, read from the pen's `POINTER_PEN_INFO` (tilt and barrel rotation where its pen mask says the pen reports them); Calligraphic and Scatter brushes vary with them (#372; not yet tried with a pen) | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: native Wayland and the web read none, and Pattern and Bristle brushes do not use them (an Art brush's Width follows them); 3–6 h (#372) | | Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | with Windows Ink, an inverted pen or its eraser button (`PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER` in the pen's pointer messages) selects the Eraser tool and the tip brings back the tool used before, as on Linux (not yet tried with a pen) | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | a press of the eraser end (Pointer Events eraser button, `button` 5) selects the Eraser tool, and that press erases; a press of the tip brings back the tool used before; Pointer Events report no end while the pen hovers | partial | | Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch, scroll and rotate | pinch, pan (a second finger cancels the first finger's tool press, #4198); two-finger tap undo, three-finger redo (#585) | same where winit reports touch | eframe until the first pen contact, then Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and touch-screen rotation missing, 3–5 h | | Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | diff --git a/docs/image-trace-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 996908799f..8181e111a3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -131,7 +131,9 @@ the suite) keep the agent to the folders you give it: written beside it by their file names. A save without a path known (`file.saveAs` and `file.saveCopy` without one, a document never saved) leaves out the suggested `folder`. `document.exportForScreens` into the `folder` of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder, and an error - for that export names the folder by its name alone. + for that export names the folder by its name alone. With Background Export on, an export into the `folder` of + its `exportPreset` runs in the background as one into a `folder` the agent gives, and its replies, errors and + status bar text name that folder the same way. `document.exportSettings`, `document.exportPresets.list` and `document.exportPresets.export` (as data and as a file) leave out a kept `folder` outside the read roots. `file.package` names the document's file and the linked files outside the read roots by their file names in `warnings`, in @@ -535,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`). | @@ -815,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`. @@ -1164,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): @@ -2415,7 +2465,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 @@ -4820,6 +4872,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 @@ -5051,7 +5127,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 @@ -5420,7 +5497,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 @@ -6234,7 +6315,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. @@ -6890,7 +6977,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, @@ -7945,20 +8032,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. @@ -7967,6 +8057,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 @@ -9575,8 +9695,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 @@ -9627,6 +9748,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 diff --git a/docs/roadmap.md b/docs/roadmap.md index f3154a5a63..175b532997 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -150,7 +150,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, eight generated libraries including Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | @@ -195,6 +195,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | +| 2026-11-12 | minor | M7: Glyphs panel applies feature-valued alternates and unencoded glyphs while keeping Arabic joining (#5514). Scores and estimates unchanged. | | 2026-11-12 | minor | M14: numeric fields keep the caret when a focus click is released more than 1 pt from its press; a tap within 1 pt still selects all (#6659, follow-up to #1326); estimates unchanged. | | 2026-11-12 | minor | M12: Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 850b87c793..efbdec482a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,9 +1,11 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; 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 (#6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > +> Glyphs update (#5514): Alternates for Current Selection applies a variant's OpenType feature to that letter alone (`salt=2`) so an Arabic word stays joined; Entire Font lists unencoded glyphs. A positional variant no supported feature draws can be applied as a glyph choice (#6084). Entire Font uses the selection's font version and variable axes, and the default cell remains available after an unlisted choice even without GSUB alternates. Only Arabic presentation-form characters insert their joining base letter. Scores and estimates unchanged. +> > Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). @@ -599,8 +601,7 @@ since 2026-10-01. - **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`. @@ -653,6 +654,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. @@ -876,6 +878,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. @@ -1181,7 +1184,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. @@ -1248,6 +1252,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. @@ -1269,6 +1274,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)). @@ -1343,23 +1349,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). @@ -1369,6 +1376,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. @@ -1571,6 +1579,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. - On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before. + - With Enable Background Export on, an Export for Screens with no folder of its own also writes in the background into the folder its `exportPreset` keeps, with the same progress dialog and Cancel, and the status bar names that folder. When that folder is outside an MCP client's read roots, the reply, its errors and the status bar name it by its own name, as for an export made at once (`dialogs::tests_screens::an_export_into_a_presets_folder_writes_in_the_background`, `fileio::tests_screens::a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out`). Behavior reference: the help page, https://helpx.adobe.com/illustrator/using/exporting-artwork.html (exports run in the background by default). - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. - The Links panel's Relink… opens a Relink dialog whose Replace menu picks which links take the new file: Selected Instances (the selected images, as before), All Instances (every image linked to the same file), Missing Links from This Folder (then every other missing link whose file name is in the new file's folder) and Each Link Separately (a file for each selected link in turn). Each relinks as one undo step (`links.relink {scope}`, `ui.relinkDialog`). Relink to Folder… stays as it is. - Linked files inside symbols, graph designs, patterns and opacity masks are saved with their paths relative to the document, as linked files in the layers are, so they are found after the document and its files move to another folder together; on open each of those links takes the path where its file was found. Update Links, and opening with Update Links: Automatically, read a modified file again for that art too, and saving a document refreshes its placed copies inside symbols and patterns of the other open documents. Evidence: `links_outside_the_layers_find_their_files_after_the_folder_moves`, `update_links_reads_files_again_inside_symbols_patterns_and_masks`, `saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns`. @@ -1594,7 +1603,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. @@ -1735,6 +1744,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. @@ -1982,6 +1992,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. @@ -2083,12 +2097,16 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | +| 2026-11-12 | minor | Glyphs review (#6084): Entire Font uses the selection's version and variable axes for previews and alternate applicability. The default cell can reset an unlisted glyph choice when GSUB lists no alternates. Encoded Latin and other ordinary characters insert unchanged even if their glyph also has Arabic positional provenance; Arabic presentation forms retain base-letter insertion. Remove a redundant charmap scan from unlisted-glyph checks. Scores and estimates unchanged. | +| 2026-11-09 | minor | Glyphs review (#6084): positional alternates with no supported feature are accepted as glyph choices in both panel views; Entire Font's unlisted-glyph and name-ownership behavior is retained. OpenType style options keep only a valid last on-value when enabling a set. Contradictory old glyph-choice evidence corrected; scores and estimates unchanged. | +| 2026-11-07 | minor | Type core: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to the selected letter alone, so an Arabic word stays joined (`salt=2`; the style commands take `tag=N`), and Show › Entire Font lists the glyphs no character maps (#5514; [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html)); evidence added; scores unchanged. | | 2026-11-12 | minor | UI chrome: a numeric-field focus click released more than 1 pt from its press keeps the caret; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326); scores and hours unchanged. | | 2026-11-03 | minor | File Info on open: an imported document's own title (an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name (tab, Save As, default export names) stays the file's name (#3626); scores and estimates unchanged. | | 2026-11-12 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does (Smart Guides, Snap to Point and the ruler guides, pixels, the grid); evidence updated; scores unchanged. | +| 2026-11-11 | minor | Interim macOS canvas cursor delivery (#2292): the tool's cursor stays after the pointer moves (#6362); tool cursors evidence updated; scores and hours unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Envelopes: with Distort Appearance, type's strokes (characters' and appearance) paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them; paths' strokes, geometry effects, gradients and patterns bend as before; also in an envelope inside an envelope; opacity masks bend as other art; Expand keeps type's strokes as strokes; the visual bounds of envelopes, blends and repeats and the reach of symbol art count type's character strokes as type's own bounds do. Changes how VectorCraft's own envelopes of stroked type draw. Advanced art evidence and `.ai` import evidence updated (#6644); scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | diff --git a/docs/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 bb2857d119..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). > @@ -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 @@ -526,6 +526,7 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ruler origin: the corner drag snaps as a drawn point does, onto the art's and the artboards' anchors, centres, edges and paths, and with Snap to Point along the ruler guides; scores and estimates unchanged | +| 2026-11-11 | minor | macOS canvas cursor delivery (#2292): while the host owns the cursor it sets it again on every pass, so the tool's cursor stays after the pointer moves (#6362); `mac_cursor` ownership test updated; scores and estimates unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). |