diff --git a/Cargo.lock b/Cargo.lock index 476af0dc89..f3e85a08a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6823,6 +6823,7 @@ dependencies = [ "skrifa 0.47.0", "svgtypes 0.16.1", "thiserror 2.0.21", + "unicode-bidi", "usvg 0.48.1", "vectorcraft-brush", "vectorcraft-color", diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 126b671877..dddaf0a91e 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -108,37 +108,14 @@ impl eframe::App for App { self.app.start_pending_update(); #[cfg(target_os = "macos")] super::mac_cursor::release(); + // Dropping the app can wait for the clipboard handles to let go: the windows leave the + // screen first (`quit_windows`). #[cfg(not(any(target_os = "macos", windows)))] - hide_before_teardown(self.winit_window.as_deref()); + super::quit_windows::before_teardown(self.winit_window.as_deref()); end_before_teardown(self.frames); } } -/// The main window as [`hide_before_teardown`] sees it. -#[cfg(not(any(target_os = "macos", windows)))] -pub(crate) trait QuittingWindow { - fn hide(&self); -} - -#[cfg(not(any(target_os = "macos", windows)))] -impl QuittingWindow for winit::window::Window { - fn hide(&self) { - self.set_visible(false); - } -} - -/// On Linux and the BSDs, hide the main window before eframe drops the app. Dropping the app waits -/// up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the clipboard handles to let go, and -/// the window would stay on screen for that time. The window is hidden, not closed: the Wayland -/// clipboard reads text on the window's connection. winit hides X11 windows (XWayland included) and -/// cannot hide a Wayland window. -#[cfg(not(any(target_os = "macos", windows)))] -pub(crate) fn hide_before_teardown(window: Option<&W>) { - if let Some(window) = window { - window.hide(); - } -} - impl App { /// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a /// focused, shown window whose windows had input waits for the renderer and the next frame, diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 3397ada7a5..c55785cfdf 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -60,6 +60,8 @@ mod portal_request; mod portal_save; mod prefs_dir; mod printing; +#[cfg(not(any(target_os = "macos", windows)))] +mod quit_windows; #[cfg(not(target_os = "macos"))] mod single_instance; #[cfg(windows)] @@ -90,8 +92,6 @@ mod titlebar; use app::bring_window_forward; use app::native_input_hook; use app::open_or_hold; -#[cfg(all(test, not(any(target_os = "macos", windows))))] -use app::{QuittingWindow, hide_before_teardown}; use host_services::services; #[cfg(all(test, feature = "wgpu"))] use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes}; @@ -122,7 +122,7 @@ struct App { /// Keeps the custom title bar's window borderless (Windows, #1758). window_frame: Option, /// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635), - /// and to hide the window on quit before the clipboard handles let go (`app::hide_before_teardown`). + /// and to take the window off screen on quit before the clipboard handles let go (`quit_windows`). winit_window: Option>, /// A saved or fitted geometry asked for maximize once the window is mapped (Linux). pending_maximize: bool, @@ -418,6 +418,10 @@ fn main() -> std::process::ExitCode { })); options }; + // Quitting takes the X11 windows of floating panels and documents off screen with the main + // window, so eframe's windows are recorded as it makes and drops them (`quit_windows`). + #[cfg(target_os = "linux")] + let options = eframe::NativeOptions { viewport_hooks: Some(std::sync::Arc::new(quit_windows::Hooks)), ..options }; #[cfg(feature = "wgpu")] let options = { let mut options = options; diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index 6d1a8734b2..65970c87f7 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -242,7 +242,7 @@ fn parent(window: &winit::window::Window) -> (String, Option<()>) { /// (), made as rfd's `window_identifier` /// makes it. #[cfg(target_os = "linux")] -mod wayland { +pub(crate) mod wayland { use wayland_client::globals::{GlobalListContents, registry_queue_init}; use wayland_client::protocol::wl_registry::{self, WlRegistry}; use wayland_client::protocol::wl_surface::WlSurface; @@ -308,9 +308,10 @@ mod wayland { } } - /// `window`'s Wayland connection and surface, from its raw handles. + /// `window`'s Wayland connection and surface, from its raw handles. The caller holds `window` + /// while it uses them (`quit_windows` unmaps the main window's surface through them). #[allow(unsafe_code)] - fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> { + pub(crate) fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> { use wayland_backend::sys::client::{Backend, ObjectId}; use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle}; let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) = @@ -319,8 +320,8 @@ mod wayland { return None; }; // SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller - // holds it until the export is dropped); a backend made from a foreign display does not - // close it when dropped. + // holds it while it uses the connection, and the export holds it until it is dropped); a + // backend made from a foreign display does not close it when dropped. let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) }; let conn = Connection::from_backend(backend); // SAFETY: the pointer is the window's live `wl_surface` proxy on that display. diff --git a/apps/vectorcraft/src/quit_windows.rs b/apps/vectorcraft/src/quit_windows.rs new file mode 100644 index 0000000000..36497f4f4f --- /dev/null +++ b/apps/vectorcraft/src/quit_windows.rs @@ -0,0 +1,280 @@ +//! Takes the app's windows off screen when it quits on Linux and the BSDs, before eframe drops the +//! app. Dropping the app waits up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the +//! clipboard handles to let go, and eframe closes the windows only after that wait. +//! +//! The main window is hidden on X11 (XWayland included). winit cannot hide a Wayland window, so on +//! Linux a Wayland main window is unmapped instead: its surface gets no buffer, and the window stays +//! open with the connection the Wayland clipboard copies and reads on. On Linux the X11 windows of +//! floating panels and documents are recorded as eframe creates and drops them ([`Hooks`]), and +//! quitting unmaps them through an X11 connection of its own. On native Wayland, floating panels and +//! documents are normally drawn inside the main window. + +#[cfg(target_os = "linux")] +use std::collections::HashMap; +#[cfg(target_os = "linux")] +use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError}; + +#[cfg(target_os = "linux")] +use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + +/// The main window as quitting sees it. +pub(crate) trait QuittingWindow { + /// The window is a Wayland surface. + fn is_wayland(&self) -> bool; + /// Hides an X11 window. + fn hide(&self); + /// Takes a Wayland window off screen and keeps it open. + fn unmap(&self); +} + +impl QuittingWindow for winit::window::Window { + #[cfg(target_os = "linux")] + fn is_wayland(&self) -> bool { + matches!(self.window_handle().map(|h| h.as_raw()), Ok(RawWindowHandle::Wayland(_))) + } + + /// The BSDs have no Wayland unmap: their windows are hidden through winit. + #[cfg(not(target_os = "linux"))] + fn is_wayland(&self) -> bool { + false + } + + fn hide(&self) { + self.set_visible(false); + } + + #[cfg(target_os = "linux")] + fn unmap(&self) { + unmap_wayland(self); + } + + #[cfg(not(target_os = "linux"))] + fn unmap(&self) { + self.set_visible(false); + } +} + +/// Takes the main window and the floating windows `x11` lists off screen: a Wayland main window is +/// unmapped and any other main window hidden, and `unmap_x11` unmaps the floating windows. +pub(crate) fn off_screen(main: Option<&W>, x11: &[u32], unmap_x11: impl FnOnce(&[u32])) { + if let Some(window) = main { + if window.is_wayland() { + window.unmap(); + } else { + window.hide(); + } + } + if !x11.is_empty() { + unmap_x11(x11); + } +} + +/// [`off_screen`] with the running app's main window and the floating windows [`Hooks`] recorded. +/// Runs on quit, before eframe drops the app. +pub(crate) fn before_teardown(main: Option<&winit::window::Window>) { + #[cfg(target_os = "linux")] + off_screen(main, &CHILD_WINDOWS.x11(), unmap_x11); + #[cfg(not(target_os = "linux"))] + off_screen(main, &[], |_| {}); +} + +/// The X11 windows of floating panels and documents, by viewport, while eframe holds them. +#[cfg(target_os = "linux")] +#[derive(Default)] +pub(crate) struct ChildWindows(Mutex>); + +#[cfg(target_os = "linux")] +impl ChildWindows { + /// eframe made `viewport`'s window, with `handle`. The main window, and windows of other window + /// systems, are not recorded; a window made again for the same viewport replaces its record. + pub(crate) fn created(&self, viewport: egui::ViewportId, handle: Option) { + let mut windows = self.lock(); + match handle.and_then(x11_window) { + Some(id) if viewport != egui::ViewportId::ROOT => windows.insert(viewport, id), + _ => windows.remove(&viewport), + }; + } + + /// eframe dropped `viewport`'s window. + pub(crate) fn destroyed(&self, viewport: egui::ViewportId) { + self.lock().remove(&viewport); + } + + /// The recorded windows, in id order. + pub(crate) fn x11(&self) -> Vec { + let mut ids: Vec = self.lock().values().copied().collect(); + ids.sort_unstable(); + ids.dedup(); + ids + } + + fn lock(&self) -> MutexGuard<'_, HashMap> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +/// The X11 window id of `handle`, None for another window system. +#[cfg(target_os = "linux")] +fn x11_window(handle: RawWindowHandle) -> Option { + match handle { + RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok(), + RawWindowHandle::Xcb(h) => Some(h.window.get()), + _ => None, + } +} + +/// The running app's floating windows, which [`Hooks`] records. +#[cfg(target_os = "linux")] +static CHILD_WINDOWS: LazyLock = LazyLock::new(ChildWindows::default); + +/// Records the windows eframe creates and drops in [`CHILD_WINDOWS`]; set as +/// [`eframe::NativeOptions::viewport_hooks`]. +#[cfg(target_os = "linux")] +pub(crate) struct Hooks; + +#[cfg(target_os = "linux")] +impl eframe::ViewportHooks for Hooks { + fn window_created(&self, viewport: egui::ViewportId, window: &winit::window::Window) { + CHILD_WINDOWS.created(viewport, window.window_handle().ok().map(|h| h.as_raw())); + } + + fn window_destroyed(&self, viewport: egui::ViewportId) { + CHILD_WINDOWS.destroyed(viewport); + } +} + +/// Unmaps the X11 `windows` through a connection of its own, and waits until the server has done so. +/// An id whose window is gone gets an error, which is ignored. +#[cfg(target_os = "linux")] +fn unmap_x11(windows: &[u32]) { + use x11rb::protocol::xproto::ConnectionExt as _; + let conn = match x11rb::connect(None) { + Ok((conn, _)) => conn, + Err(e) => { + log::warn!("couldn't reach the X server to hide the floating windows on quit: {e}"); + return; + } + }; + for &window in windows { + if let Ok(cookie) = conn.unmap_window(window) { + cookie.ignore_error(); + } + } + // The server answers once it has handled the requests before this one. + if let Ok(cookie) = conn.get_input_focus() { + let _ = cookie.reply(); + } +} + +/// Attaches no buffer to `window`'s Wayland surface and commits it, which unmaps the window: the +/// compositor stops showing it, and the window and its connection stay open. +#[cfg(target_os = "linux")] +fn unmap_wayland(window: &winit::window::Window) { + let Some((conn, surface)) = super::portal_save::wayland::surface(window) else { return }; + surface.attach(None, 0, 0); + surface.commit(); + if let Err(e) = conn.flush() { + log::warn!("couldn't reach the compositor to hide the window on quit: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + /// A main window that records what quitting did to it. + struct Window<'a> { + wayland: bool, + done: &'a RefCell>, + } + + impl QuittingWindow for Window<'_> { + fn is_wayland(&self) -> bool { + self.wayland + } + fn hide(&self) { + self.done.borrow_mut().push("hide".into()); + } + fn unmap(&self) { + self.done.borrow_mut().push("unmap".into()); + } + } + + fn quit(wayland: Option, x11: &[u32]) -> Vec { + let done = RefCell::new(Vec::new()); + let window = wayland.map(|wayland| Window { wayland, done: &done }); + off_screen(window.as_ref(), x11, |ids| done.borrow_mut().push(format!("unmap x11 {ids:?}"))); + done.into_inner() + } + + /// winit cannot hide a Wayland window: quitting unmaps it, and never only asks winit to hide it. + #[test] + fn quitting_unmaps_a_wayland_main_window() { + assert_eq!(quit(Some(true), &[]), ["unmap"]); + } + + /// An X11 main window (XWayland included) is hidden, and the floating panels' and documents' X11 + /// windows are unmapped with it. + #[test] + fn quitting_hides_an_x11_main_window_and_its_floating_windows() { + assert_eq!(quit(Some(false), &[]), ["hide"]); + assert_eq!(quit(Some(false), &[7, 9]), ["hide", "unmap x11 [7, 9]"]); + } + + /// Without a main window (none was made), the floating windows still go, and with none at all + /// nothing is done. + #[test] + fn quitting_without_a_main_window() { + assert_eq!(quit(None, &[3]), ["unmap x11 [3]"]); + assert!(quit(None, &[]).is_empty()); + } + + #[cfg(target_os = "linux")] + fn xlib(id: std::os::raw::c_ulong) -> Option { + Some(RawWindowHandle::Xlib(winit::raw_window_handle::XlibWindowHandle::new(id))) + } + + #[cfg(target_os = "linux")] + fn viewport(name: &str) -> egui::ViewportId { + egui::ViewportId::from_hash_of(name) + } + + /// The floating windows eframe made are recorded until it drops them; the main window, which + /// quitting reaches through winit, is not. + #[cfg(target_os = "linux")] + #[test] + fn floating_x11_windows_are_recorded_while_they_exist() { + let windows = ChildWindows::default(); + windows.created(egui::ViewportId::ROOT, xlib(1)); + windows.created(viewport("Layers"), xlib(9)); + let xcb = winit::raw_window_handle::XcbWindowHandle::new(std::num::NonZeroU32::new(5).expect("not zero")); + windows.created(viewport("Untitled-2"), Some(RawWindowHandle::Xcb(xcb))); + windows.created(viewport("Color"), xlib(7)); + assert_eq!(windows.x11(), [5, 7, 9]); + windows.destroyed(viewport("Color")); + assert_eq!(windows.x11(), [5, 9]); + // A viewport that eframe has already dropped, or never made, is no error. + windows.destroyed(viewport("Color")); + windows.destroyed(viewport("Swatches")); + assert_eq!(windows.x11(), [5, 9]); + } + + /// A window made again for a viewport replaces its record, and one of another window system, or + /// one whose handle could not be read, removes it. + #[cfg(target_os = "linux")] + #[test] + fn a_window_made_again_replaces_its_record() { + let windows = ChildWindows::default(); + windows.created(viewport("Layers"), xlib(9)); + windows.created(viewport("Layers"), xlib(11)); + assert_eq!(windows.x11(), [11]); + let surface = std::ptr::NonNull::::dangling(); + let wayland = winit::raw_window_handle::WaylandWindowHandle::new(surface); + windows.created(viewport("Layers"), Some(RawWindowHandle::Wayland(wayland))); + assert!(windows.x11().is_empty()); + windows.created(viewport("Layers"), xlib(11)); + windows.created(viewport("Layers"), None); + assert!(windows.x11().is_empty()); + } +} diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 1994569c0c..54fa01da03 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -515,21 +515,3 @@ fn the_linux_desktop_entry_opens_every_readable_format() { } } } - -/// On Linux and the BSDs, quitting hides the main window before eframe drops the app, whose -/// clipboard handles may take up to `CLIPBOARD_PROBE_STOP_WAIT` to let go. -#[cfg(not(any(target_os = "macos", windows)))] -#[test] -fn quitting_hides_the_window_before_the_clipboards_let_go() { - struct Window(std::cell::Cell); - impl QuittingWindow for Window { - fn hide(&self) { - self.0.set(self.0.get() + 1); - } - } - let window = Window(std::cell::Cell::new(0)); - hide_before_teardown(Some(&window)); - assert_eq!(window.0.get(), 1, "the window was not hidden"); - // Without a window (none was made), there is nothing to hide. - hide_before_teardown::(None); -} diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 43074a4e80..d561663524 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -363,6 +363,14 @@ pub struct SeriesPaint { /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal. #[serde(skip_serializing_if = "Option::is_none")] pub blend: Option, + /// A gradient in [`Self::fill`] is placed relative to the unit box (0, 0)–(1, 1), and each mark takes it on its own + /// geometric bounds. False: the gradient is in document coordinates, and every mark takes it unchanged. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub fill_unit_box: bool, + /// A gradient in [`Self::stroke`] is placed relative to the unit box, as [`Self::fill_unit_box`], and each mark takes + /// it on its stroke's box (the geometric bounds grown by half the weight). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stroke_unit_box: bool, } /// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 440c379283..c3c1464cfc 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -18,7 +18,7 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; pub use char_style::{ - CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, + CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_TSUME, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, WARICHU_SCALE, WarichuAlign, }; pub use composite::{ diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 16b63e0809..ef3cf29dc9 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -44,6 +44,14 @@ pub struct CharStyle { /// Insert Aki Right: space added after the character in reading order, as [`Self::aki_left`]. #[serde(default, rename = "akiRight", skip_serializing_if = "crate::skip::is_default")] pub aki_right: f64, + /// Tsume (Character panel, East Asian options): the percentage of the blank space on each side + /// of the character's ink, inside its own width, that is taken away, 0 to [`MAX_TSUME`]. 50 + /// halves that space and 100 sets the ink edge to edge; the glyph itself is not scaled. A + /// cluster takes the value of its first character. Tracking, kerning and Insert Aki are added + /// after it. Horizontal type only. + /// + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub tsume: f64, /// Use glyph shapes for automatic pair spacing; false uses font metrics. #[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")] pub optical_kerning: bool, @@ -440,6 +448,9 @@ fn hundred() -> f64 { /// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em. pub const MAX_AKI: f64 = 1000.0; +/// The largest Tsume ([`CharStyle::tsume`]), in percent: all the blank space beside the ink. +pub const MAX_TSUME: f64 = 100.0; + /// The number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]). pub const WARICHU_LINES: std::ops::RangeInclusive = 1..=6; @@ -534,6 +545,7 @@ impl Default for CharStyle { kerning_before: 0.0, aki_left: 0.0, aki_right: 0.0, + tsume: 0.0, optical_kerning: false, baseline_shift: 0.0, h_scale: 100.0, @@ -588,7 +600,8 @@ impl CharStyle { /// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a /// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right /// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to - /// [`MAX_AKI`], and one that is not a number becomes 0. The Warichu Settings Lines and Scale + /// [`MAX_AKI`], and one that is not a number becomes 0; Tsume is kept from 0 to [`MAX_TSUME`] the + /// same way. The Warichu Settings Lines and Scale /// are kept to [`WARICHU_LINES`] and [`WARICHU_SCALE`]; a gap or scale that is not a number becomes its default. The gap is kept to 0 to /// [`crate::MAX_COORD`], as the baseline shift is, since a scaled type object holds it in its /// own units. @@ -606,6 +619,7 @@ impl CharStyle { let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) }; self.aki_left = aki(self.aki_left); self.aki_right = aki(self.aki_right); + self.tsume = if self.tsume.is_nan() { 0.0 } else { self.tsume.clamp(0.0, MAX_TSUME) }; self.warichu_lines = self.warichu_lines.clamp(*WARICHU_LINES.start(), *WARICHU_LINES.end()); self.warichu_gap = if self.warichu_gap.is_nan() { default.warichu_gap } else { self.warichu_gap.clamp(0.0, crate::MAX_COORD) }; self.warichu_scale = diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index dd4002dd60..9af4d9c5dc 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -507,6 +507,7 @@ impl crate::Document { st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right); st.aki_left = num("akiLeft").unwrap_or(st.aki_left); st.aki_right = num("akiRight").unwrap_or(st.aki_right); + st.tsume = num("tsume").unwrap_or(st.tsume); st.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap); st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale); st.keep_dimensions_in_range(); @@ -532,6 +533,7 @@ impl crate::Document { ("tcyLeftRight", Some(st.tcy_left_right)), ("akiLeft", Some(st.aki_left)), ("akiRight", Some(st.aki_right)), + ("tsume", Some(st.tsume)), ("leading", st.leading), ]; for (k, v) in kept { diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index b8531b402d..26622fa115 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -357,7 +357,7 @@ impl ParaList { /// The style the marker is set in: `first` (the style of the paragraph's first character) /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, - /// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough. + /// Insert Aki, Tsume, tate-chu-yoko, warichu, underline and strikethrough. pub fn marker_style(first: &CharStyle) -> CharStyle { CharStyle { baseline_shift: 0.0, @@ -368,6 +368,7 @@ impl ParaList { kerning_before: 0.0, aki_left: 0.0, aki_right: 0.0, + tsume: 0.0, optical_kerning: first.optical_kerning, tate_chu_yoko: false, warichu: false, diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 0f474d2ed8..c3afafb6e1 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -129,6 +129,34 @@ fn insert_aki_is_saved_only_when_set() { assert_eq!((d.char_styles[0].attrs["akiLeft"].as_f64(), d.char_styles[0].attrs["akiRight"].as_f64()), (Some(MAX_AKI), Some(0.0))); } +/// Tsume is 0 by default and saved only when not 0; styles written before it read as 0, and a +/// value out of range from a file, in a run or a character style, is kept from 0 to 100. +#[test] +fn tsume_is_saved_only_when_set_and_kept_in_range() { + let st = CharStyle::default(); + assert_eq!(st.tsume, 0.0); + let saved = serde_json::to_value(&st).unwrap(); + assert!(saved.get("tsume").is_none()); + let read = serde_json::from_value::(saved).unwrap(); + assert_eq!(read.tsume, 0.0); + let set = CharStyle { tsume: 37.5, ..CharStyle::default() }; + let saved = serde_json::to_value(&set).unwrap(); + assert_eq!(saved.get("tsume").and_then(serde_json::Value::as_f64), Some(37.5)); + assert_eq!(serde_json::from_value::(saved).unwrap(), set); + for (stored, kept) in [(-5.0, 0.0), (250.0, MAX_TSUME), (f64::NAN, 0.0), (f64::INFINITY, MAX_TSUME), (50.0, 50.0)] { + let mut damaged = CharStyle { tsume: stored, ..CharStyle::default() }; + damaged.keep_dimensions_in_range(); + assert_eq!(damaged.tsume, kept, "{stored}"); + } + let mut d = crate::Document::new(100.0, 100.0); + for tsume in [500, -1] { + let attrs = serde_json::json!({ "tsume": tsume }); + d.char_styles.push(TextStyleDef { name: format!("Tight {tsume}"), attrs: attrs.as_object().cloned().unwrap_or_default() }); + } + d.keep_type_values_in_range(); + assert_eq!((d.char_styles[0].attrs["tsume"].as_f64(), d.char_styles[1].attrs["tsume"].as_f64()), (Some(MAX_TSUME), Some(0.0))); +} + /// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its /// script's block; every system saves under its id, which reads back, and other names are refused. #[test] diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 90a0d85ec8..2cffad6581 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints}; use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, - has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, + has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, }; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { @@ -58,6 +58,7 @@ pub fn needs_bake(n: &Node) -> bool { || has_geometry(&n.appearance.effects) || n.projection().is_some() || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) + || has_instance_row_raster(n) || n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c))) } @@ -142,9 +143,11 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { } // Type, images, symbol instances and live objects: reshaped through their outlines, and so are // type whose own fills or strokes have geometry effects and images whose own fills or strokes - // paint a shape ([`has_item_geometry`]). Type that Transform effects only move and copy stays - // type, moved to each place. - if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) { + // paint a shape ([`has_item_geometry`]), and symbol instances whose active fill or stroke has a + // raster effect, which the fills and strokes of their art then carry + // ([`has_instance_row_raster`]). Type that Transform effects only move and copy stays type, + // moved to each place. + if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n) || has_instance_row_raster(n)) { if let Some(m) = moved_type_group(n) { return Some(bake_pieces(d, m)); } diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index bb2f605a91..4df0a105ce 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -661,8 +661,9 @@ pub fn has_container_appearance(n: &Node) -> bool { /// The regions a group's or layer's own fills and strokes paint, in document space, each with its /// fill rule: every visible member path and compound path, type as its glyph outlines and live /// objects evaluated; a compound shape as its area, with the symbol instances among its members -/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its -/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the +/// as their symbols' art from `doc`; a clip group or clipping layer (`n` itself or one among its +/// members) as the region its clipping path clips to ([`crate::clip::clip_outline`]), so members +/// it hides add nothing. Images, other symbol instances and guides add nothing. A Repeat counts the /// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget. pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> { fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { @@ -690,8 +691,14 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul } } fn members(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { - let clip = usize::from(n.clips()); - for c in n.children().into_iter().flatten().skip(clip) { + // A clip group shows its members only inside its clipping path: its outline is that + // region, not the clipped members' own shapes, which may reach far outside it (#7289). + if n.clips() { + let clip = n.children().and_then(|ch| ch.first()); + out.extend(clip.and_then(|c| crate::clip::clip_outline(c)).map(|(bp, rule)| (PathData::from_bezpath(&bp), rule))); + return; + } + for c in n.children().into_iter().flatten() { push(doc, c, out); } } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index c6e2a78c73..8e00718c01 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -1757,3 +1757,38 @@ fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked() assert_eq!(guides, [(NodeId(20), !hidden, locked)], "hidden {hidden}, locked {locked}"); } } + +/// A group's own Fill + Offset Path below its contents follows the outline of a clip group among +/// its members, its clipping path, not the clipped art it hides outside that path (#7289). +#[test] +fn a_groups_own_fill_follows_a_clip_groups_clipping_path_not_its_hidden_art() { + let mut clip = square_at_100(); + clip.id = NodeId(2); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + // The clipped art reaches 100 pt past the clipping path on either side. + let hidden = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 100.0, 300.0, 200.0)), black()); + let clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(hidden)], clip: true }); + let mut g = Node::group(NodeId(10), vec![Arc::new(clipped.clone())]); + let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + fill.effects.push(offset_10()); + g.appearance.items = vec![AppearanceItem::Fill(fill)]; + g.appearance.set_contents_at(1); + let near = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y1 - b.y1).abs() < 0.5; + let expected = Rect::new(90.0, 90.0, 210.0, 210.0); + + let art = evaluate_container(None, &g).unwrap(); + let ch = art.children().unwrap(); + assert_eq!(ch.len(), 2, "the fill's art below the clip group"); + assert_eq!(ch[0].id, NodeId(10)); + let b = drawn_bounds(&ch[0]).unwrap(); + assert!(near(b, expected), "the offset fill hugs the clipping path: {b:?}"); + + // The clip group itself with that fill: the same outline. + let mut own = clipped; + own.appearance = g.appearance.clone(); + assert_eq!(member_shapes(None, &own).len(), 1); + let b = member_shapes(None, &own)[0].0.bounds().unwrap(); + assert!(near(b, Rect::new(100.0, 100.0, 200.0, 200.0)), "the clipping path's region: {b:?}"); +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 6316a7a273..c18a31d842 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -80,8 +80,9 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry}; pub use reshape::{ - SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, - has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, + SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry, + has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, + reshape, }; pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 8c829d9e0b..83ff68431f 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -162,15 +162,26 @@ fn active_stroke(n: &Node) -> Option<&vectorcraft_doc::StrokeLayer> { n.appearance.stroke().filter(|s| s.visible && s.opacity > 0.0) } -/// The visible geometry effects in `fx`, in order. -fn geometry_of(fx: &[Effect]) -> Vec { - fx.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect() +/// Is `n` a symbol instance whose active fill or stroke has a visible raster effect (a shadow, glow, +/// blur, feather or other painted effect, [`crate::is_raster`])? Its art takes that effect on each +/// fill or stroke in it that paints ([`add_instance_row_effects`]), and draws it there. +pub fn has_instance_row_raster(n: &Node) -> bool { + let raster = |fx: &[Effect]| fx.iter().any(|e| e.visible && crate::is_raster(&e.id)); + matches!(n.kind, NodeKind::SymbolInstance { .. }) + && (active_fill(n).is_some_and(|f| raster(&f.effects)) || active_stroke(n).is_some_and(|s| raster(&s.effects))) } -/// `art`, the art symbol instance `n` places, with the visible geometry effects of `n`'s active -/// fill added after the effects of every fill in it that paints, and those of its active stroke -/// after the effects of every stroke in it that paints ([`active_fill`], [`active_stroke`]). Each -/// fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are +/// The visible geometry and raster effects in `fx`, in order: those a symbol instance's active fill +/// or stroke passes on to its art. +fn row_effects_of(fx: &[Effect]) -> Vec { + fx.iter().filter(|e| e.visible && (is_geometry(&e.id) || crate::is_raster(&e.id))).cloned().collect() +} + +/// `art`, the art symbol instance `n` places, with the visible geometry and raster effects of +/// `n`'s active fill added after the effects of every fill in it that paints, and those of its +/// active stroke after the effects of every stroke in it that paints ([`active_fill`], +/// [`active_stroke`]): a Drop Shadow on the instance's fill shadows each filled shape in the art. +/// Each fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are /// reached are evaluated first (with the symbols of `doc`), so the effects reshape what they draw. /// Clip groups' clipping paths, guides and the art of opacity masks keep their shapes. A symbol /// instance in the art takes the effects on its own active fill or stroke, or on a new fill or @@ -180,8 +191,8 @@ fn geometry_of(fx: &[Effect]) -> Vec { /// instance in the art takes them. fn add_instance_row_effects(doc: Option<&Document>, n: &Node, art: &mut Node) { let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return }; - let fill = active_fill(n).map_or(vec![], |f| geometry_of(&f.effects)); - let stroke = active_stroke(n).map_or(vec![], |s| geometry_of(&s.effects)); + let fill = active_fill(n).map_or(vec![], |f| row_effects_of(&f.effects)); + let stroke = active_stroke(n).map_or(vec![], |s| row_effects_of(&s.effects)); if !fill.is_empty() || !stroke.is_empty() { add_row_effects(doc, symbol, art, &fill, &stroke); } @@ -526,8 +537,8 @@ fn with_id(art: &Node, id: NodeId) -> Node { /// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by /// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), as /// the art its members draw when the instance has geometry effects of its own -/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry effects of the -/// instance's active fill and stroke ([`add_instance_row_effects`]). +/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry and raster +/// effects of the instance's active fill and stroke ([`add_instance_row_effects`]). /// `None` for other kinds (images, paths, containers), and for an instance whose art placed /// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of /// `doc`'s symbols that its instances show toward its instance budget. @@ -709,12 +720,14 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping /// reshaped with it, and keep their effects from their first Convert to Shape or Outline Object /// on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask /// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective -/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts, -/// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is -/// always reshaped. The fills and strokes in the art whose own Transform effects reach their -/// appearance become objects of their own ([`transform_items`]). +/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts +/// or a symbol instance whose active fill or stroke has a raster effect +/// ([`has_instance_row_raster`]), or has no art (a symbol instance without `symbol_art`). A path +/// with [`has_transform_art`] is always reshaped. The fills and strokes in the art whose own +/// Transform effects reach their appearance become objects of their own ([`transform_items`]). pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { - if !has_transform_art(n) && ((geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n)) { + let own = !geometry_effects(n).is_empty() || n.projection().is_some() || has_item_geometry(n) || has_instance_row_raster(n); + if !has_transform_art(n) && (!own || !needs_outline(n)) { return None; } as_art(doc, n, symbol_art) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 74b23340cd..4b23c3af8c 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -370,7 +370,9 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes))) } -/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts +/// One object's surface of revolution about the axis of `b`, and each face it draws ([`Drawn`]), +/// back to front: by depth, and where two faces overlap, by their depths there +/// ([`crate::solids::surface_order`]). `copies` counts /// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face /// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the /// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]): @@ -388,7 +390,7 @@ fn surface( blend: vectorcraft_doc::color::BlendMode, expand: bool, copies: &mut Copies, -) -> Option<(Vec, Node)> { +) -> Option<(Vec, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. if fx.id == CLASSIC_REVOLVE { @@ -397,9 +399,12 @@ fn surface( if copies.surfaces > MAX_PAINT_COPIES { return None; } - let depths = facets.iter().map(|f| f.depth).collect(); + let order = crate::solids::surface_order(facets.iter().map(|f| (f.contours.first().map_or(&[][..], Vec::as_slice), f.plane, f.depth))); + let facets = in_order(facets, &order); + let drawn = + facets.iter().map(|f| Drawn { depth: f.depth, points: f.contours.first().cloned().unwrap_or_default(), plane: f.plane }).collect(); let p = merged_params(&fx.id, &fx.params); - return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); + return Some((drawn, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); } let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; @@ -407,6 +412,8 @@ fn surface( if copies.surfaces > MAX_PAINT_COPIES { return None; } + let order = crate::solids::surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth))); + let faces = in_order(faces, &order); let graphics = crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)); let shade = |face: &vectorcraft_three_d::Face, c: Color| graphic_paint(face, c, colored); // Graphics past the limits are left out, and the surface draws without them and their paints' @@ -437,21 +444,28 @@ fn surface( && n.has_default_transparency()) .then(|| vectorcraft_three_d::visible_faces(&faces).ok()) .flatten(); - let faces = visible.unwrap_or_else(|| { - faces - .into_iter() - .map(|f| vectorcraft_three_d::VisibleFace { - path: f.path(), - brightness: f.brightness, - depth: f.depth, - gradient: f.gradient, - illumination: f.illumination, - light_gradient: f.light_gradient, - rgb_gradient: f.rgb_gradient, - }) - .collect() - }); - let depths: Vec = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect(); + let (faces, keys): (Vec, Vec) = match visible { + Some(visible) => { + let keys = visible.iter().map(|f| Drawn { depth: f.depth, points: vec![], plane: None }).collect(); + (visible, keys) + } + None => { + let keys = faces.iter().map(|f| Drawn { depth: f.depth, points: f.points.clone(), plane: f.plane }).collect(); + let faces = faces + .into_iter() + .map(|f| vectorcraft_three_d::VisibleFace { + path: f.path(), + brightness: f.brightness, + depth: f.depth, + gradient: f.gradient, + illumination: f.illumination, + light_gradient: f.light_gradient, + rgb_gradient: f.rgb_gradient, + }) + .collect(); + (faces, keys) + } + }; let children = faces .into_iter() .map(|face| { @@ -517,7 +531,21 @@ fn surface( let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; - Some((depths, group)) + Some((keys, group)) +} + +/// A face [`surface`] draws: its depth and, for [`crate::solids::surface_order`], its polygon on the +/// drawing and its plane (none for a face trimmed to the part of it that shows). +struct Drawn { + depth: f64, + points: Vec, + plane: Option, +} + +/// `items` in `order`, a permutation of their indices. +fn in_order(items: Vec, order: &[usize]) -> Vec { + let mut slots: Vec> = items.into_iter().map(Some).collect(); + order.iter().filter_map(|&k| slots.get_mut(k).and_then(Option::take)).collect() } /// A surface of one object about the bounds of `path`, built with options derived from `fx`. @@ -934,14 +962,17 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } else { let count = surfaces.len(); let mut faces = vec![]; - for (member, (depths, s, scopes)) in surfaces.into_iter().enumerate() { + for (member, (drawn, s, scopes)) in surfaces.into_iter().enumerate() { let (opacity, blend) = (s.opacity, s.blend); if let NodeKind::Group { children, .. } = s.kind { - faces.extend(depths.into_iter().zip(children).map(|(depth, face)| (depth, member, opacity, blend, scopes, face))); + faces.extend(drawn.into_iter().zip(children).map(|(drawn, face)| (drawn, member, opacity, blend, scopes, face))); } } - // A stable sort: faces at the same depth keep the members' order. - faces.sort_by(|a, b| a.0.total_cmp(&b.0)); + // A stable sort: faces at the same depth keep the members' order. Overlapping faces, of one + // member or of two, then go in the order of their depths where they overlap. + faces.sort_by(|a, b| a.0.depth.total_cmp(&b.0.depth)); + let order = crate::solids::surface_order(faces.iter().map(|(d, ..)| (d.points.as_slice(), d.plane, d.depth))); + let faces = in_order(faces, &order); if opaque { vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(.., face)| face).collect()))] } else { diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7500af0617..f9a5a1abf6 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask}; use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2}; use vectorcraft_pathops::BoolOp; -use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; +use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, MeshedSolid, PlanarFace, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -656,7 +656,7 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { #[cfg(test)] thread_local! { - /// How many solids [`built_solids`] has built on this thread, for the tests. + /// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests. static SOLIDS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -1261,6 +1261,35 @@ fn draw_order(faces: &[Placed], sealed: bool) -> Vec { local_order(faces, order) } +/// The order, back to front, of the faces of one or more Revolve surfaces `faces`, given back to +/// front by depth: each face's polygon on the drawing, its plane and its depth. When two +/// overlapping faces are drawn the wrong way round for their depths where they overlap, every pair +/// of overlapping faces with a convex polygon and a plane goes in the order of its depths there, +/// the face nearer the viewer after the other ([`local_order`]), and each step draws the face that +/// comes first in the given order of those whose faces before are drawn, so a face without a plane +/// or a convex polygon can also move. Otherwise the order given. +pub(crate) fn surface_order<'a>(faces: impl IntoIterator, f64)>) -> Vec { + let placed: Vec = faces + .into_iter() + .map(|(points, plane, depth)| { + let local = plane.is_some() && convex(points).is_some(); + // Every face counts as one of a stroke's on one shape, so each overlapping pair is compared. + Placed { + depth, + side: true, + away: false, + bounds: polygon_bounds(points), + member: 0, + paints: Paints::Other, + outline: if local { points.to_vec() } else { vec![] }, + plane: plane.filter(|_| local), + } + }) + .collect(); + let order = (0..placed.len()).collect(); + local_order(&placed, order) +} + /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` /// its 3D effect, about the bounds of all of them. pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { @@ -2039,31 +2068,35 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) return Ok(out); } let (parts, bounds) = solid_parts(shapes, b); - // The solids the art draws, past the limits of the object's solids together as it is - // ([`built_solids`]), each built and its faces counted as the loop reaches it, then dropped. - let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some())); - // Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel - // solid and its mesh are built as the loop reaches them. + // Inflate lays out every puff at once (they share one eye). Each Inflate and Extrude & Bevel + // solid is built once, as the loop reaches it: its facets decide whether the art draws it, as + // [`built_solids`] does, and its faces before projection make its mesh. let mut inflated = (fx.id == INFLATE).then(|| { let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); - vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) + vectorcraft_three_d::inflate_meshed(&shapes, bounds, inflate_options(&p), view) }); let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) }; - for ((item, member, area, rule, _, _), drawn) in parts.iter().zip(drawn) { + let mut kept = 0usize; + for (item, member, area, rule, _, _) in &parts { let item: &AppearanceItem = item; - if !drawn? { - // A stroke the art leaves out is left out of the meshes too; Inflate's next puff - // still belongs to the next part. - if let Some(built) = inflated.as_mut() { - built.next(); - } - continue; - } - let mesh = match inflated.as_mut() { - Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), - None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view), + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); + let built = match inflated.as_mut() { + Some(each) => each.next().unwrap_or_else(|| Ok(MeshedSolid::default())), + None => vectorcraft_three_d::extrude_meshed(area, *rule, bounds, extrude, view), }; - match mesh { + // A stroke the art leaves out is left out of the meshes too. The faces of the solids kept + // count together against the object's limit. + let solid = match built { + Ok(solid) => solid, + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => continue, + Err(e) => return Err(e), + }; + kept = kept.saturating_add(solid.facets.len()); + if kept > MAX_OBJECT_FACES { + return Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()); + } + match solid.mesh() { Ok(mesh) => { let (paint, opacity, _) = paint_of(item, *member); keep(&mut out, mesh, mesh_color(&paint, opacity))?; @@ -2174,3 +2207,6 @@ mod tests_sources; #[cfg(test)] mod tests_shadow; + +#[cfg(test)] +mod tests_revolve_order; diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs index aeefafbc77..a9de7cd7f9 100644 --- a/crates/effects/src/solids/tests_mesh.rs +++ b/crates/effects/src/solids/tests_mesh.rs @@ -208,3 +208,151 @@ fn a_gradient_takes_the_average_of_its_stops() { let pattern = Paint::Pattern { pattern: "x".into(), xf: vectorcraft_geom::Affine::IDENTITY }; assert!(close(mesh_color(&pattern, 0.5), [0.5, 0.5, 0.5, 0.5])); } + +/// The meshes of Extrude & Bevel or Inflate `fx` on `shapes`, each built apart from the facets that +/// decide which solids are drawn ([`built_solids`], then [`vectorcraft_three_d::extrude_mesh`] or +/// [`vectorcraft_three_d::inflate_meshes`]), for comparing with [`meshes_for`]. +fn meshes_built_apart(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result, String> { + let p = crate::merged_params(&fx.id, &fx.params); + let view = view_options(&p); + let (parts, bounds) = solid_parts(shapes, union_bounds(shapes).unwrap()); + let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some())); + let mut inflated = (fx.id == INFLATE).then(|| { + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) + }); + let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) }; + let mut out = vec![]; + for ((item, opacity, area, rule, _, _), drawn) in parts.iter().zip(drawn) { + let item: &AppearanceItem = item; + if !drawn? { + if let Some(built) = inflated.as_mut() { + built.next(); + } + continue; + } + let mesh = match inflated.as_mut() { + Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), + None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view), + }; + match mesh { + Ok(mesh) => { + let (paint, opacity, _) = paint_of(item, *opacity); + keep_mesh(&mut out, total, SolidMesh { mesh, color: mesh_color(&paint, opacity) }, limit)?; + } + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} + Err(e) => return Err(e), + } + } + Ok(out) +} + +/// The bits of every number in `meshes`, for comparing two exports exactly. +type MeshBits = Vec<([u32; 4], Vec<[u64; 3]>, Vec<[u64; 3]>, Vec<[u32; 3]>)>; + +fn mesh_bits(meshes: &[SolidMesh]) -> MeshBits { + meshes + .iter() + .map(|m| { + let (positions, normals) = (&m.mesh.positions, &m.mesh.normals); + ( + m.color.map(f32::to_bits), + positions.iter().map(|p| p.map(f64::to_bits)).collect(), + normals.iter().map(|n| n.map(f64::to_bits)).collect(), + m.mesh.triangles.clone(), + ) + }) + .collect() +} + +/// A five-pointed star about `(x, 150)` with curved edges, filled red and stroked black. +fn star_node(id: u64, x: f64) -> Node { + let mut b = BezPath::new(); + for k in 0..10 { + let (s, c) = (k as f64 * std::f64::consts::PI / 5.0).sin_cos(); + let r = if k % 2 == 0 { 60.0 } else { 25.0 }; + let p = (x + r * c, 150.0 + r * s); + if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 4.0, p.1), (p.0, p.1 + 4.0), p) } + } + b.close_path(); + Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 6.0)) +} + +/// The shapes `meshes_for` builds from: one node's, or each of several. +fn shapes_of(nodes: &[&Node]) -> Vec { + nodes + .iter() + .map(|n| Profile { path: profile(n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(n), opacity: 1.0, scopes: vec![] }) + .collect() +} + +/// Building each Extrude & Bevel and Inflate solid once exports the same meshes, to the bit, as +/// building its facets and its mesh apart, within and past the triangle budget. +#[test] +fn one_build_exports_the_meshes_built_apart() { + let mut second_stroke = square_node(3, 400.0); + if let Some(stroke) = second_stroke.appearance.items.get(1).cloned() { + second_stroke.appearance.items.push(stroke); + } + let (star, square, other) = (star_node(1, 100.0), square_node(2, 250.0), star_node(4, 600.0)); + let cases = [ + (EXTRUDE, json!({})), + (EXTRUDE, json!({ "bevel": "rounded", "bevelWidth": 6.0, "bevelHeight": 5.0, "twist": 60.0, "taper": 70.0 })), + (EXTRUDE, json!({ "bevel": "cove", "bevelInside": true, "bevelBothSides": true, "cap": false, "perspectiveAngle": 50.0 })), + (EXTRUDE, json!({ "depth": 0.0 })), + (CLASSIC_EXTRUDE, json!({ "bevel": "classic", "bevelHeight": 8.0 })), + (INFLATE, json!({})), + (INFLATE, json!({ "depth": 30.0, "bothSides": true, "volume": 80.0 })), + (INFLATE, json!({ "depth": 40.0, "twist": 360.0, "taper": 70.0, "cap": false })), + (INFLATE, json!({ "depth": 0.1, "twist": 0.1, "bothSides": true })), + ]; + let sets: [Vec<&Node>; 3] = [vec![&star], vec![&second_stroke], vec![&star, &square, &other]]; + for (id, params) in cases { + let effect = fx(id, params.clone()); + for nodes in &sets { + let shapes = shapes_of(nodes); + let (mut once, mut apart) = (0, 0); + let built = meshes_for(&effect, &shapes, &mut once, MAX_MESH_TRIANGLES).unwrap_or_else(|e| panic!("{id} {params}: {e}")); + let reference = meshes_built_apart(&effect, &shapes, &mut apart, MAX_MESH_TRIANGLES).unwrap(); + assert!(!reference.is_empty()); + assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id} {params}"); + assert_eq!(once, apart); + // Past the budget, at the same mesh, with the same count. + let limit = once - 1; + let (mut once, mut apart) = (0, 0); + let built = meshes_for(&effect, &shapes, &mut once, limit); + assert!(built.is_err(), "{id} {params}"); + assert_eq!(built.err(), meshes_built_apart(&effect, &shapes, &mut apart, limit).err(), "{id} {params}"); + assert_eq!(once, apart, "{id} {params}"); + } + } +} + +/// A stroke whose solid is past the limits is left out of the export and the fill's mesh exports, +/// as when the facets and the mesh are built apart. +#[test] +fn a_stroke_past_the_limits_is_left_out_of_the_export() { + // 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline. + let points: Vec = (0..40_000) + .map(|k| { + let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos(); + let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 }; + Point::new(20_000.0 + r * c, 20_000.0 + r * s) + }) + .collect(); + let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + let n = Node::path(NodeId(1), path, Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 2.0)); + for id in [EXTRUDE, INFLATE] { + // Without caps: a cap of 40,000 corners takes more work to cut into triangles than an + // export allows. + let effect = fx(id, json!({ "cap": false })); + let shapes = shapes_of(&[&n]); + // The fill's mesh alone has more triangles than an export takes. + let (mut once, mut apart) = (0, 0); + let built = meshes_for(&effect, &shapes, &mut once, usize::MAX).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(built.len(), 1, "{id}: the fill's mesh alone"); + assert!(close(built[0].color, [0.8, 0.2, 0.1, 1.0]), "{id}"); + let reference = meshes_built_apart(&effect, &shapes, &mut apart, usize::MAX).unwrap(); + assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id}"); + } +} diff --git a/crates/effects/src/solids/tests_revolve_order.rs b/crates/effects/src/solids/tests_revolve_order.rs new file mode 100644 index 0000000000..96650cf2f8 --- /dev/null +++ b/crates/effects/src/solids/tests_revolve_order.rs @@ -0,0 +1,183 @@ +use serde_json::json; + +use super::*; +use crate::revolve::{CLASSIC_REVOLVE, REVOLVE, revolve_art, revolve_options}; + +/// The first pair of faces `(polygon, plane)`, in the order given, drawn the wrong way round where +/// their polygons overlap: the earlier one nearer the viewer at the middle of the overlap. +fn misordered(faces: &[(Vec, Option)]) -> Option<(usize, usize)> { + for (i, (a, pa)) in faces.iter().enumerate() { + let Some(pa) = pa.filter(|_| convex(a).is_some()) else { continue }; + for (j, (b, pb)) in faces.iter().enumerate().skip(i + 1) { + let Some(pb) = pb else { continue }; + let Some(turn) = convex(b) else { continue }; + if !rects_overlap(polygon_bounds(a), polygon_bounds(b)) { + continue; + } + let Some(mid) = centroid(&clip_convex(a, b, turn)) else { continue }; + let (Some(da), Some(db)) = (pa.depth_at(mid), pb.depth_at(mid)) else { continue }; + if da - db > 1e-6 * (1.0 + da.abs().max(db.abs())) { + return Some((i, j)); + } + } + } + None +} + +/// An open, vase-like profile of straight edges. +fn vase() -> PathData { + let mut b = BezPath::new(); + b.move_to((30.0, 20.0)); + for p in [(90.0, 20.0), (55.0, 65.0), (95.0, 110.0), (30.0, 145.0)] { + b.line_to(p); + } + PathData::from_bezpath(&b) +} + +/// The `k`th of five closed 14 × 20 pt rectangles stepping down and away from the axis, as the +/// dashes of a dashed line's outline do. +fn dash(k: usize) -> PathData { + let (x, y) = (30.0 + 12.0 * k as f64, 20.0 + 26.0 * k as f64); + let mut b = BezPath::new(); + b.move_to((x, y)); + b.line_to((x + 14.0, y)); + b.line_to((x + 14.0, y + 20.0)); + b.line_to((x, y + 20.0)); + b.close_path(); + PathData::from_bezpath(&b) +} + +/// The five [`dash`]es as one compound path. +fn dashes() -> PathData { + PathData::new((0..5).flat_map(|k| dash(k).subpaths).collect()) +} + +/// A tilted three-quarter turn of [`vase`], whose faces sorted by their middles' depths draw a +/// nearer face under a farther one where the two overlap. +fn params() -> Value { + json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 24, "cap": true}) +} + +/// A three-quarter turn of [`dashes`], steeply tilted and in wide perspective, whose classic facets +/// sorted by their middles' depths draw a nearer facet under a farther one where the two overlap. +fn classic_params() -> Value { + json!({"angle": 270, "offset": 12, "rotationX": -60, "rotationY": 0, "perspectiveAngle": 80, "cap": true}) +} + +fn node(path: PathData) -> Node { + Node::path(NodeId(1), path, Appearance::basic(Paint::solid(Color::rgb(0.1, 0.7, 0.8)), Paint::None, 0.0)) +} + +/// The faces of `faces` (polygon, plane and outline) in the order `art` draws their outlines. +fn drawn_order(art: &Node, faces: &[(Vec, Option, PathData)]) -> Vec<(Vec, Option)> { + let mut out = vec![]; + art.walk(&mut |n| { + if let Some(path) = n.path_data() + && let Some((points, plane, _)) = faces.iter().find(|(.., outline)| outline == path) + { + out.push((points.clone(), *plane)); + } + }); + out +} + +/// Of a tilted, revolved vase, a face sorted by the depth of its middle goes under a farther face +/// that it is in front of where the two overlap. The surface draws the nearer face last there, on +/// its own and as a member of a revolved group. +#[test] +fn a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last() { + let path = vase(); + let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(¶ms())).unwrap(); + let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect(); + assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere"); + let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect(); + let mut n = node(path.clone()); + n.appearance.effects.push(crate::new_effect(REVOLVE, ¶ms()).unwrap()); + let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed); + assert_eq!(drawn.len(), faces.len()); + assert_eq!(misordered(&drawn), None, "an object"); + let mut g = Node::group(NodeId(9), vec![Arc::new(node(path))]); + g.appearance.effects.push(crate::new_effect(REVOLVE, ¶ms()).unwrap()); + let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed); + assert_eq!(drawn.len(), faces.len()); + assert_eq!(misordered(&drawn), None, "a group's member"); +} + +/// The faces of a group's members, sorted together by the depths of their middles, go in the order +/// of their depths where they overlap, those of two members as well as those of one. +#[test] +fn a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last() { + let p = json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 16, "cap": true}); + let members: Vec = (0..5).map(dash).collect(); + let b = dashes().bounds().unwrap(); + let mut faces: Vec<_> = members.iter().flat_map(|m| vectorcraft_three_d::revolve(m, b, revolve_options(&p)).unwrap()).collect(); + faces.sort_by(|a, b| a.depth.total_cmp(&b.depth)); + let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect(); + assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere"); + let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect(); + let mut g = Node::group(NodeId(9), members.into_iter().map(|m| Arc::new(node(m))).collect()); + g.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap()); + let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed); + assert_eq!(drawn.len(), faces.len()); + assert_eq!(misordered(&drawn), None); +} + +/// Revolve (Classic) draws the nearer of two overlapping facets last where they overlap, too. +#[test] +fn a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last() { + let path = dashes(); + // Revolve (Classic) sweeps its surface unshaded, with one facet for each cell. + let classic = vectorcraft_three_d::Revolve { + shade: false, + smooth_shading: false, + lights: None, + light_color: [1.0; 3], + ..revolve_options(&crate::merged_params(CLASSIC_REVOLVE, &classic_params())) + }; + let facets = vectorcraft_three_d::revolve_facets(&path, path.bounds().unwrap(), classic).unwrap(); + let by_depth: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane)).collect(); + assert!(misordered(&by_depth).is_some(), "the facets by depth are drawn the wrong way round somewhere"); + let keyed: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane, f.path())).collect(); + let mut n = node(path); + n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &classic_params()).unwrap()); + let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed); + assert_eq!(drawn.len(), facets.len()); + assert_eq!(misordered(&drawn), None); +} + +/// A surface whose faces by depth are already drawn the right way round wherever they overlap keeps +/// that order. +#[test] +fn a_surface_already_in_order_keeps_it() { + let path = vase(); + let p = json!({"angle": 360, "offset": 12, "rotationX": 0, "rotationY": 0, "segments": 24, "cap": true}); + let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(&p)).unwrap(); + let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect(); + assert_eq!(misordered(&by_depth), None); + let order = surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth))); + assert_eq!(order, (0..faces.len()).collect::>()); +} + +/// Of two overlapping faces, the one nearer the viewer where they overlap goes after the other, +/// whatever their depths; a face without a plane, and one that overlaps neither, keep their places. +/// Two faces at the same depth there keep their order. +#[test] +fn surface_order_puts_the_nearer_face_where_they_overlap_last() { + let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]; + // z = 10 - x: at the middle of its overlap with the next square (x 7.5) it is at z 2.5. + let r = std::f64::consts::FRAC_1_SQRT_2; + let sloped = Some(DepthPlane { normal: [r, 0.0, r], offset: 10.0 * r, center: Point::ZERO, eye: None }); + let flat = |z: f64| Some(DepthPlane { normal: [0.0, 0.0, 1.0], offset: z, center: Point::ZERO, eye: None }); + let (a, b, apart) = (square(0.0), square(5.0), square(40.0)); + let faces = [(&a[..], sloped, 5.0), (&apart[..], flat(0.0), 1.0), (&b[..], flat(3.0), 3.0)]; + assert_eq!(surface_order(faces), vec![0, 1, 2], "the flat face, nearer at the overlap, is already last"); + let faces = [(&b[..], flat(2.0), 2.0), (&apart[..], flat(0.0), 3.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![0, 1, 2], "the sloped face, nearer at the overlap, is already last"); + // The sloped face's middle (x 5) is at z 5, but the flat face at z 3 is nearer at the overlap. + let faces = [(&b[..], flat(3.0), 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![1, 2, 0], "the flat face is nearer at the overlap"); + let faces = [(&b[..], None, 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![0, 1, 2], "no plane"); + let faces = [(&b[..], flat(2.5), 3.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![0, 1], "the same depth there"); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index d4fb2c39cd..19695fd4ff 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -559,6 +559,15 @@ pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result usize { usize::try_from(v).unwrap_or(usize::MAX) } +/// The whole number of 0 or more that `p` gives for `key`, read with [`index`], or `None` when `p` +/// leaves it out or gives null. Any other value, such as `"3"`, `2.5`, `-1` or `true`, is an error +/// under `cmd`: "`{key}` must be a whole number". +pub(crate) fn index_opt(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(|n| Some(index(n))).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))), + } +} /// Validate all explicit targets before a command changes the document or selection. pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { checked_nodes_param(s, p, key, command, false) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 576b05ffd6..9ecb5c3984 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -568,12 +568,11 @@ fn switch_units(s: &mut Session, _: &Value) -> Result { Ok(json!({ "units": next.label() })) } -/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given. +/// The `artboard` param of the ruler origin commands: an artboard index, 0 when left out or null. A +/// value that is not a whole number is "`artboard` must be a whole number"; one past the artboards +/// is "index out of range". fn artboard_param(s: &Session, p: &Value, c: &str) -> Result { - let index = match p.get("artboard") { - None => 0, - Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?, - }; + let index = super::index_opt(p, "artboard", c)?.unwrap_or(0); if index >= s.doc()?.doc.artboards.len() { return Err(bad(c, "index out of range")); } @@ -583,14 +582,15 @@ fn artboard_param(s: &Session, p: &Value, c: &str) -> Result { fn ruler_origin(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; // A document without artboards measures from its global zero point. - let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? }; + let index = + if d.artboards.is_empty() && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, "document.rulerOrigin")? }; Ok(json!(s.doc()?.doc.ruler_origin_for(index))) } fn set_ruler_origin(s: &mut Session, p: &Value) -> Result { const C: &str = "document.setRulerOrigin"; let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); - let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? }; + let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, C)? }; change_ruler_origin(s, index, Some(origin), "Ruler Origin") } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 6ee3905d95..65672f8b6b 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -865,8 +865,8 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> { /// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several /// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with /// fills, strokes or effects of its own or such members, other objects with effects, type with -/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry -/// effect ([`effects::has_item_geometry`])? +/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry or +/// raster effect ([`effects::has_item_geometry`], [`effects::has_instance_row_raster`])? pub(crate) fn expandable(n: &Node) -> bool { expandable_except(n, &[]) } @@ -883,7 +883,9 @@ pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool { !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip)) } NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(), - NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(), + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { + effects::has_item_geometry(n) || effects::has_instance_row_raster(n) || !n.appearance.effects.is_empty() + } _ => !n.appearance.effects.is_empty(), } } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 1ff1897c85..84fb121d0c 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -70,7 +70,7 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub(crate) use export::with_artboard_fills; pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated}; -pub use export::{export_doc, export_source, unused_option_notes}; +pub use export::{export_doc, export_source, selection_doc, unused_option_notes}; use load::err; pub(crate) use load::import_svg; pub(crate) use load::is_placeholder; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 03736c6022..a225bfcad9 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})", has_selection, export::export_selection ), diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 594193cd7b..a2e9393ee0 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -295,8 +295,14 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C /// ([`isolated`]), with one artboard: their crop. With `text`, the objects alone on one layer, so a /// text export holds the type of the selected objects and not that of a clipping path around them. fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> { - let ids = selected_artwork(s)?; - let doc = &s.doc()?.doc; + selection_doc(s.doc()?, cmd, text) +} + +/// The document an Export Selection of `st` writes and its crop, as `document.exportSelection` +/// writes it ([`selection`]); `cmd` names the command in the error when nothing selected draws. +pub fn selection_doc(st: &DocState, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> { + let ids = artwork_of(st); + let doc = &st.doc; let (mut d, bounds) = isolated(doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))?; if text { let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); @@ -312,12 +318,16 @@ fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vector /// layers are guides and are left out. With an object of a dynamic symbol instance's art selected, /// the instance is exported whole. pub(crate) fn selected_artwork(s: &Session) -> Result> { - let st = s.doc()?; + Ok(artwork_of(s.doc()?)) +} + +/// [`selected_artwork`] of document `st`. +fn artwork_of(st: &DocState) -> Vec { let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc)); let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); // On a template layer or sublayer, at any depth. let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); - Ok(ids.into_iter().filter(|id| !on_template(*id)).collect()) + ids.into_iter().filter(|id| !on_template(*id)).collect() } /// The crop of an export of objects `ids` of `doc` alone ([`isolated`]): the bounds the renderer diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 54ca07da7e..b47fd0e5e3 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label", has_doc, set_gradient_geom ), diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index a789304b24..69510652b9 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -6,7 +6,7 @@ use super::labels::{ recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; -use super::series::{series_marks, with_series_opacity}; +use super::series::{series_marks, with_series_gradients, with_series_opacity}; use super::{ Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes, }; @@ -1006,7 +1006,7 @@ impl<'a> Plot<'a> { } } let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1)); - let grp = self.b.series(&name, s, with_series_opacity(g, s, items)); + let grp = self.b.series(&name, s, with_series_opacity(g, s, with_series_gradients(g, s, items))); self.b.out.push(grp); } self.b.out.append(&mut self.on_top); diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 51a7ead550..aeaceb9e91 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -2,7 +2,7 @@ use vectorcraft_color::BlendMode; use super::axis::radar_axis; use super::labels::{Role, filled_lines_on, label_para, label_style}; -use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; +use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad}; // ---------- generation ---------- @@ -229,7 +229,8 @@ pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, b continue; } let (paint, width) = shown_paint(node, *fill); - captures.push((r.graph, r.index, *fill, paint, (!*fill).then_some(width))); + let width = (!*fill).then_some(width); + captures.push((r.graph, r.index, *fill, paint, width, paint_box(node, width))); } store_series_paints(doc, captures) } @@ -281,11 +282,129 @@ fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool changed } -/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a +/// The box a series keeps a gradient relative to: (0, 0)–(1, 1), as graphic styles keep theirs. +const UNIT_BOX: Rect = vectorcraft_doc::GraphicStyle::UNIT_BOX; + +/// The gradient `slot` keeps for its fill (`fill`) or stroke as the graph draws it on box `on`: one kept relative to the +/// unit box placed on `on`, also when `on` has no width or no height, and one kept in document coordinates as it is kept. +/// None when the slot keeps no gradient, or keeps one relative to the unit box and there is no box. +fn kept_on(slot: &vectorcraft_doc::SeriesPaint, fill: bool, on: Option) -> Option { + let (kept, unit) = if fill { (&slot.fill, slot.fill_unit_box) } else { (&slot.stroke, slot.stroke_unit_box) }; + let Some(Paint::Gradient(g)) = kept else { return None }; + if !unit { + return Some(Paint::Gradient(g.clone())); + } + let on = on?; + let mut g = g.clone(); + g.rebase(UNIT_BOX, on); + Some(Paint::Gradient(g)) +} + +/// Whether `a` and `b` are the same paint, a gradient's vector, focal point and freeform points each within 1e-6 pt (the +/// rounding a move or a placement on another box leaves). +fn same_paint_placed(a: &Paint, b: &Paint) -> bool { + let (Paint::Gradient(a), Paint::Gradient(b)) = (a, b) else { return a == b }; + let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| (p - q).hypot() <= 1e-6; + let mut a = (**a).clone(); + match (&mut a.geom, &b.geom) { + (Some(ag), Some(bg)) => { + let focal = match (ag.focal, bg.focal) { + (Some(p), Some(q)) => near(p, q), + (p, q) => p.is_none() && q.is_none(), + }; + if !(near(ag.start, bg.start) && near(ag.end, bg.end) && focal) { + return false; + } + (ag.start, ag.end, ag.focal) = (bg.start, bg.end, bg.focal); + } + (None, None) => {} + _ => return false, + } + match (&mut a.freeform, &b.freeform) { + (Some(af), Some(bf)) => { + if af.points.len() != bf.points.len() || !af.points.iter().zip(&bf.points).all(|(p, q)| near(p.at, q.at)) { + return false; + } + for (p, q) in af.points.iter_mut().zip(&bf.points) { + p.at = q.at; + } + } + (None, None) => {} + _ => return false, + } + a == **b +} + +/// The box a gradient on `node`'s fill (`width` None) or on its stroke of `width` is placed on: the geometric bounds, grown +/// by half the weight for a stroke. The box can have no width or no height. +fn paint_box(node: &Node, width: Option) -> Option { + let b = node.geometric_bounds()?; + Some(width.map_or(b, |w| vectorcraft_doc::appearance::stroke_paint_bounds(b, w))) +} + +/// `paint` as a series keeps it: a gradient placed on `from` (the edited mark's [`paint_box`]) is moved to the same place +/// relative to [`UNIT_BOX`], so each mark takes it on its own box. A box with no width or no height, which a gradient's +/// place cannot be kept relative to, leaves the paint in document coordinates. Returns the paint and whether it was moved. +fn unit_box_paint(paint: Paint, from: Option) -> (Paint, bool) { + match (paint, from.filter(|b| b.width() > 1e-9 && b.height() > 1e-9)) { + (Paint::Gradient(mut g), Some(from)) => { + g.rebase(from, UNIT_BOX); + (Paint::Gradient(g), true) + } + (paint, _) => (paint, false), + } +} + +/// The paths directly inside series `i`'s group (`items`) with the series' kept gradients, which are kept relative to the +/// unit box, placed on each path's own box: a fill on its geometric bounds, a stroke on its stroke's box. Paints kept in +/// document coordinates (from files saved before the unit box) are left as they are. +pub(super) fn with_series_gradients(g: &GraphSpec, i: usize, mut items: Vec>) -> Vec> { + let Some(slot) = g.series_paints.get(i) else { return items }; + fn kept(p: &Option, unit: bool) -> Option<&Paint> { + p.as_ref().filter(|p| unit && matches!(p, Paint::Gradient(_))) + } + let (fill, stroke) = (kept(&slot.fill, slot.fill_unit_box), kept(&slot.stroke, slot.stroke_unit_box)); + if fill.is_none() && stroke.is_none() { + return items; + } + // A Filled Lines band takes the kept stroke as its fill when no fill is kept. + let band_fill = if slot.fill.is_none() { stroke } else { None }; + let fill_from_store = |p: &Paint| fill == Some(p) || band_fill == Some(p); + let stroke_from_store = |p: &Paint| stroke == Some(p); + for item in &mut items { + if !matches!(item.kind, NodeKind::Path { .. }) { + continue; + } + let Some(b) = item.geometric_bounds() else { continue }; + let ap = &item.appearance; + if !ap.fill().is_some_and(|f| fill_from_store(&f.paint)) && !ap.stroke().is_some_and(|st| stroke_from_store(&st.paint)) { + continue; + } + let ap = &mut Arc::make_mut(item).appearance; + if let Some(f) = ap.fill_mut().filter(|f| fill_from_store(&f.paint)) + && let Paint::Gradient(gp) = &mut f.paint + { + gp.rebase(UNIT_BOX, b); + } + if let Some(st) = ap.stroke_mut().filter(|st| stroke_from_store(&st.paint)) { + let to = st.paint_bounds(b); + if let Paint::Gradient(gp) = &mut st.paint { + gp.rebase(UNIT_BOX, to); + } + } + } + items +} + +/// A paint taken from a series mark: graph, series index, fill or stroke, paint, stroke weight and the mark's +/// [`paint_box`]. +type Capture = (NodeId, usize, bool, Paint, Option, Option); + +/// Store each capture in its series; a gradient is kept relative to the unit box ([`unit_box_paint`]). Returns whether a /// series' captured fill or stroke differs from what the graph stored before the first capture. -fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option)>) -> bool { +fn store_series_paints(doc: &mut Document, captures: Vec) -> bool { let mut before = SeriesPaintsBefore::new(); - for (graph, index, fill, paint, width) in captures { + for (graph, index, fill, paint, width, from) in captures { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; before.entry(graph).or_insert_with(|| spec.series_paints.clone()); // `index` is below the graph's series count (`series_members`). @@ -293,10 +412,17 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); } let Some(slot) = spec.series_paints.get_mut(index) else { continue }; + // A mark that shows the kept gradient as it was drawn leaves the kept gradient as it is. + let drawn = kept_on(slot, fill, from).is_some_and(|kept| same_paint_placed(&kept, &paint)); + let (paint, unit) = unit_box_paint(paint, from); if fill { - slot.fill = Some(paint); + if !drawn { + (slot.fill, slot.fill_unit_box) = (Some(paint), unit); + } } else { - slot.stroke = Some(paint); + if !drawn { + (slot.stroke, slot.stroke_unit_box) = (Some(paint), unit); + } slot.stroke_width = width; } } diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index c997e803d5..140fb3c933 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -508,6 +508,89 @@ fn paint_targets_leave_a_graph_designs_copy_out() { assert_eq!(super::paint_targets(d, &[copy, art, mark]), [mark]); } +/// The art of a marker design in series 1 of a line graph: the design's copy and a path inside it. +fn marker_design_art() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Pill"})).unwrap(); + let marker = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].clone(); + assert!(matches!(marker.kind, NodeKind::Group { .. }), "the design's art"); + let mut art = vec![]; + marker.walk(&mut |m| { + if matches!(m.kind, NodeKind::Path { .. }) { + art.push(m.id); + } + }); + (s, marker.id, art[0]) +} + +/// The ids `graphicStyle.list` reports as linked to style `name`. +fn linked_to(s: &mut Session, name: &str) -> Vec { + let list = s.execute("graphicStyle.list", &json!({})).unwrap(); + let style = list["styles"].as_array().unwrap().iter().find(|g| g["name"] == name).unwrap().clone(); + let mut ids: Vec = style["linked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + ids.sort_unstable(); + ids +} + +/// Graphic styles and the art of a column or marker design in a series (the design's copy and a path inside it): +/// `graphicStyle.new` makes a style from the art without linking it; `graphicStyle.redefine` leaves the art's look as +/// it was and unlinks it, whether the art is linked (as in a document saved before) or the source, while the other +/// linked objects follow the new look. +#[test] +fn graphic_styles_leave_a_graph_designs_art_unlinked() { + let make = |marker: bool| { + if marker { + marker_design_art() + } else { + let (s, _, copy, art, _) = design_art(); + (s, copy, art) + } + }; + let rows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().appearance.clone(); + let link = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().extras().graphic_style; + let link_directly = |s: &mut Session, ids: &[NodeId], style: u32| { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + for id in ids { + d.node_mut(*id).unwrap().extras_mut().graphic_style = Some(style); + } + }; + let rect = |s: &mut Session| { + NodeId(s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()) + }; + for marker in [false, true] { + for source in [0, 1] { + let at = format!("marker {marker}, source {source}"); + // New: the style is made from the art, which stays unlinked. + let (mut s, copy, art) = make(marker); + let source = [copy, art][source]; + s.execute("graphicStyle.new", &json!({"id": source.0, "name": "Art"})).unwrap(); + assert_eq!(link(&s, source), None, "{at}"); + assert_eq!(linked_to(&mut s, "Art"), Vec::::new(), "{at}"); + // Redefine from another object: linked art keeps its look and is unlinked; a linked rectangle follows. + let follower = rect(&mut s); + s.execute("graphicStyle.apply", &json!({"name": "Art", "ids": [follower.0]})).unwrap(); + let style = link(&s, follower).unwrap(); + link_directly(&mut s, &[copy, art], style); + let (copy_was, art_was) = (rows(&s, copy), rows(&s, art)); + let (blue_rect, blue) = blue_style(&mut s, "Blue", false); + s.execute("graphicStyle.redefine", &json!({"name": "Art", "id": blue_rect.0})).unwrap(); + assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was.clone(), art_was.clone()), "{at}"); + assert_eq!((link(&s, copy), link(&s, art)), (None, None), "{at}"); + assert_eq!(rows(&s, follower).fill_paint(), blue, "{at}"); + assert_eq!(linked_to(&mut s, "Art"), [follower.0.min(blue_rect.0), follower.0.max(blue_rect.0)], "{at}"); + // Redefine from the art itself: the art is unlinked and the linked rectangle takes the art's look. + link_directly(&mut s, &[copy, art], style); + s.execute("graphicStyle.redefine", &json!({"name": "Art", "id": source.0})).unwrap(); + assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was, art_was), "{at}"); + assert_eq!((link(&s, copy), link(&s, art)), (None, None), "{at}"); + assert_eq!(linked_to(&mut s, "Art"), [follower.0.min(blue_rect.0), follower.0.max(blue_rect.0)], "{at}"); + } + } +} + /// The art of a marker design in a series is refused too. #[test] fn effect_commands_refuse_a_marker_designs_art() { @@ -2754,10 +2837,39 @@ fn a_gradient_edit_on_a_series_mark_is_kept() { series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was); } -/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored on the graph as it is placed, and -/// every mark of the series has that fill, placement included, after the graph is drawn again. +/// Where gradient `paint` sits on box `b`: its vector's start and end, then each freeform point, as fractions of the box. +fn gradient_place(paint: &Paint, b: vectorcraft_geom::Rect) -> Vec { + let Paint::Gradient(g) = paint else { panic!("not a gradient: {paint:?}") }; + let points = g.geom.iter().flat_map(|geom| [geom.start, geom.end]).chain(g.freeform.iter().flat_map(|f| f.points.iter().map(|p| p.at))); + points.flat_map(|p| [(p.x - b.x0) / b.width(), (p.y - b.y0) / b.height()]).collect() +} + +/// The box a gradient on `mark`'s fill (`stroke` false) or stroke is placed on. +fn paint_box_of(mark: &vectorcraft_doc::Node, stroke: bool) -> vectorcraft_geom::Rect { + let b = mark.geometric_bounds().unwrap(); + if stroke { vectorcraft_doc::appearance::stroke_paint_bounds(b, mark.appearance.stroke_width()) } else { b } +} + +/// The fill (`stroke` false) or stroke of every path of series 0, the legend swatch included, has a gradient at `place` on +/// its own box, and the gradients are not all at one place in the document. Returns how many paths were checked. +fn series_0_gradients_at(s: &Session, id: NodeId, stroke: bool, place: &[f64]) -> usize { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let paths: Vec<_> = series(&n, 0).children().unwrap().iter().filter(|m| matches!(m.kind, NodeKind::Path { .. })).cloned().collect(); + let paints: Vec = paths.iter().map(|m| if stroke { m.appearance.stroke_paint() } else { m.appearance.fill_paint() }).collect(); + for (m, paint) in paths.iter().zip(&paints) { + let at = gradient_place(paint, paint_box_of(m, stroke)); + assert_eq!(at.len(), place.len(), "{at:?}"); + assert!(at.iter().zip(place).all(|(a, b)| (a - b).abs() < 1e-9), "{at:?} is not {place:?}"); + } + assert!(paints.windows(2).any(|w| w[0] != w[1]), "every mark has the gradient at one place: {paints:?}"); + paths.len() +} + +/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored relative to the mark's box, and every +/// mark of the series has it at the same place on its own box after the graph is drawn again, also after a data change +/// moves and resizes the marks and after the graph is moved. #[test] -fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() { +fn a_gradient_placed_on_a_series_mark_takes_the_same_place_on_every_mark() { let (mut s, id, _, marks) = column_marks(); // `paint.setFill` stores the gradient before it is placed. s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); @@ -2766,26 +2878,258 @@ fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() { s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); let placed = fill_of(&s, marks[0]); assert_ne!(placed, fill, "the command places the gradient"); - assert_eq!(spec(&s, id).series_paints[0].fill, Some(placed.clone())); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); - assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0)); + let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let place = gradient_place(&placed, paint_box_of(&mark, false)); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.fill_unit_box); + assert_eq!(gradient_place(kept.fill.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX), place); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + assert!(series_0_gradients_at(&s, id, false, &place) >= 4); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 50, "dy": 30})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 2], [5, 4], [1, 1]]})).unwrap(); + series_0_gradients_at(&s, id, false, &place); } -/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph. +/// A gradient placed on a series mark's stroke is kept relative to the mark's stroke box, and every mark of the series has +/// it at the same place on its own stroke box after the graph is drawn again. +#[test] +fn a_gradient_placed_on_a_series_marks_stroke_takes_the_same_place_on_every_mark() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap(); + s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 4})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone(); + let place = gradient_place(&mark.appearance.stroke_paint(), paint_box_of(&mark, true)); + assert!(spec(&s, id).series_paints[0].stroke_unit_box); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + series_0_gradients_at(&s, id, true, &place); +} + +/// A placed gradient picked up with the Eyedropper from art outside the graph is kept relative to the series mark it is +/// dropped on, and every mark of the series has it at the same place on its own box. +#[test] +fn a_placed_gradient_dropped_on_a_series_mark_takes_the_same_place_on_every_mark() { + let (mut s, id, _, marks) = column_marks(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [r.0], "start": [610, 600], "end": [630, 620]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": r.0, "ids": [marks[2].0]})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[2]).unwrap().clone(); + let place = gradient_place(&mark.appearance.fill_paint(), paint_box_of(&mark, false)); + assert!(spec(&s, id).series_paints[0].fill_unit_box); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + series_0_gradients_at(&s, id, false, &place); +} + +/// A graphic style with a placed gradient applied to a series mark is kept relative to that mark's box, and every mark of +/// the series has the gradient at the same place on its own box. +#[test] +fn a_graphic_style_gradient_applied_to_a_series_mark_takes_the_same_place_on_every_mark() { + let (mut s, id, _, marks) = column_marks(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [r.0], "start": [610, 600], "end": [630, 620]})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": r.0, "name": "Placed"})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Placed", "ids": [marks[2].0]})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[2]).unwrap().clone(); + let place = gradient_place(&mark.appearance.fill_paint(), paint_box_of(&mark, false)); + assert!(spec(&s, id).series_paints[0].fill_unit_box); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + series_0_gradients_at(&s, id, false, &place); +} + +/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph, +/// and every mark of the series has the gradient's points at the same place on its own box after the graph is drawn again. #[test] fn a_freeform_gradient_edit_on_a_series_mark_is_kept() { let (mut s, id, _, marks) = column_marks(); let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": "#ff8000"}]); // `paint.setFill` stores the gradient before its point is recolored. s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap(); - let fill = fill_of(&s, marks[0]); - assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); s.execute("paint.freeform.setPoint", &json!({"ids": [marks[0].0], "index": 1, "color": "#00ff00"})).unwrap(); let edited = fill_of(&s, marks[0]); - assert_ne!(edited, fill, "the command recolors the point"); - assert_eq!(spec(&s, id).series_paints[0].fill, Some(edited.clone())); + let Paint::Gradient(g) = &edited else { panic!("{edited:?}") }; + assert_eq!(g.freeform.as_ref().unwrap().points[1].color.to_hex(), "#00ff00"); + let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let place = gradient_place(&edited, paint_box_of(&mark, false)); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.fill_unit_box); + assert_eq!(gradient_place(kept.fill.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX), place); s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); - assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == edited), "{:?}", marks_of(&s, id, 0)); + series_0_gradients_at(&s, id, false, &place); +} + +/// A gradient a graph kept in document coordinates (a file saved before gradients were kept relative to the unit box) is +/// drawn on every mark as it was kept. +#[test] +fn a_gradient_kept_in_document_coordinates_is_drawn_as_kept() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); + let placed = fill_of(&s, marks[0]); + s.edit("Kept in document coordinates", |d, _| { + let mut extras = d.node_mut(id).unwrap().extras_mut(); + let slot = &mut extras.graph.as_mut().unwrap().series_paints[0]; + (slot.fill, slot.fill_unit_box) = (Some(placed.clone()), false); + Ok(()) + }) + .unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0)); + // The same gradient kept for the fill relative to the unit box leaves the stroke kept in document coordinates as it is. + s.edit("Kept fill and stroke", |d, _| { + let mut extras = d.node_mut(id).unwrap().extras_mut(); + let slot = &mut extras.graph.as_mut().unwrap().series_paints[0]; + (slot.fill, slot.fill_unit_box) = (Some(placed.clone()), true); + (slot.stroke, slot.stroke_unit_box, slot.stroke_width) = (Some(placed.clone()), false, Some(2.0)); + Ok(()) + }) + .unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 2], [3, 4]]})).unwrap(); + let drawn = marks_of(&s, id, 0); + assert!(drawn.iter().all(|m| m.1 == placed), "{drawn:?}"); + series_0_gradients_at(&s, id, false, &gradient_place(&placed, vectorcraft_doc::GraphicStyle::UNIT_BOX)); +} + +/// A gradient placed on a series path with no height (a flat line's fill) is kept in document coordinates, and every +/// path of the series that takes the kept fill has it at that place in the document. +#[test] +fn a_gradient_placed_on_a_series_path_with_no_height_is_kept_in_document_coordinates() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "line"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 2], [3, 4], [3, 6]]})).unwrap(); + let flat = |s: &Session| { + let parts = series_parts(s, id, 0); + parts.into_iter().find(|m| m.appearance.fill_paint().is_none() && m.geometric_bounds().is_some_and(|b| b.height() < 1e-9)).unwrap().id + }; + let line = flat(&s); + s.execute("paint.setFill", &json!({"ids": [line.0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [line.0], "start": [150, 120], "end": [300, 280]})).unwrap(); + let placed = fill_of(&s, line); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(!kept.fill_unit_box); + assert_eq!(kept.fill, Some(placed.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 1], [3, 5], [3, 2]]})).unwrap(); + let fills: Vec = series_parts(&s, id, 0).iter().map(|m| m.appearance.fill_paint()).filter(|p| matches!(p, Paint::Gradient(_))).collect(); + assert!(fills.len() >= 2 && fills.iter().all(|p| *p == placed), "{fills:?}"); +} + +/// Writing a series mark's stroke weight as it is, after the graph drew the mark's stroke from a gradient the series +/// keeps relative to the unit box, leaves the kept gradient, the document and its undo history as they are, on every mark. +#[test] +fn rewriting_a_series_marks_drawn_gradient_stroke_records_no_undo_step() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap(); + s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 4})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1.3, 2], [3.7, 4], [6.1, 1]]})).unwrap(); + let was = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let drawn: Vec<_> = series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).collect(); + assert!(drawn.len() >= 4, "{}", drawn.len()); + for mark in drawn { + let row = top_row(&s, mark.id, false); + s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap(); + assert!(s.doc().unwrap().doc == was, "writing mark {:?} changed the document", mark.id); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + // A mark moved with its gradient shows the kept gradient on its moved box, up to rounding. + let kept = spec(&s, id).series_paints[0].clone(); + for (i, mark) in series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).enumerate() { + s.execute("select.set", &json!({"ids": [mark.id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 13.37 + i as f64 * 0.731, "dy": -7.19})).unwrap(); + let moved = s.doc().unwrap().doc.node(mark.id).unwrap().geometric_bounds().unwrap(); + assert!((moved.x0 - mark.geometric_bounds().unwrap().x0 - 13.37 - i as f64 * 0.731).abs() < 1e-9, "mark {:?} did not move", mark.id); + let undo = s.doc().unwrap().history.undo.len(); + let row = top_row(&s, mark.id, false); + s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept, "writing moved mark {:?} changed the kept stroke", mark.id); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } +} + +/// Writing the fill of a series mark with no height (a column for a value of 0) as it is, after the graph drew the mark's +/// fill from a gradient the series keeps relative to the unit box, leaves the kept gradient and its unit-box flag as they +/// are. +#[test] +fn rewriting_a_drawn_gradient_fill_on_a_series_mark_with_no_height_keeps_the_kept_gradient() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.fill_unit_box); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[0, 2], [3, 4], [6, 1]]})).unwrap(); + let flat = series_parts(&s, id, 0) + .into_iter() + .find(|m| matches!(m.appearance.fill_paint(), Paint::Gradient(_)) && m.geometric_bounds().is_some_and(|b| b.height() < 1e-9)) + .unwrap() + .id; + let changed = s.edit("Write the fill as it is", |d, _| Ok(super::capture_series_top_paints(d, &[(flat, true)]))).unwrap(); + assert!(!changed); + assert_eq!(spec(&s, id).series_paints[0], kept); +} + +/// Writing a series mark's stroke weight as it is, after the graph drew the mark's stroke from a gradient the series keeps +/// in document coordinates (a file saved before the unit box), leaves the kept gradient and its unit-box flag as they are +/// and records no undo step. +#[test] +fn rewriting_a_series_marks_stroke_drawn_from_a_gradient_kept_in_document_coordinates_keeps_it() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap(); + s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 2})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap(); + let placed = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.stroke_paint(); + s.edit("Kept in document coordinates", |d, _| { + let mut extras = d.node_mut(id).unwrap().extras_mut(); + let slot = &mut extras.graph.as_mut().unwrap().series_paints[0]; + (slot.stroke, slot.stroke_unit_box, slot.stroke_width) = (Some(placed.clone()), false, Some(2.0)); + Ok(()) + }) + .unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + let undo = s.doc().unwrap().history.undo.len(); + let drawn: Vec<_> = series_parts(&s, id, 0).into_iter().filter(|m| m.appearance.stroke_paint() == placed).collect(); + assert!(drawn.len() >= 3, "{}", drawn.len()); + for mark in drawn { + let row = top_row(&s, mark.id, false); + s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept, "writing mark {:?} changed the kept stroke", mark.id); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } +} + +/// A gradient placed on a line series' stroke is kept relative to the line's stroke box, and with Draw Filled Lines the +/// band, which takes the kept stroke as its fill, has it at the same place on its own box. +#[test] +fn a_gradient_kept_for_a_lines_stroke_takes_the_same_place_on_its_filled_line() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "line"); + let line = series_parts(&s, id, 0).into_iter().find(|m| m.appearance.fill_paint().is_none()).unwrap().id; + s.execute("paint.setStroke", &json!({"ids": [line.0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [line.0], "stroke": true, "start": [150, 120], "end": [300, 280]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.stroke_unit_box && kept.fill.is_none()); + let place = gradient_place(kept.stroke.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); + let band = series_parts(&s, id, 0).into_iter().find(|m| matches!(m.appearance.fill_paint(), Paint::Gradient(_))).unwrap(); + let at = gradient_place(&band.appearance.fill_paint(), band.geometric_bounds().unwrap()); + assert!(at.iter().zip(&place).all(|(a, b)| (a - b).abs() < 1e-9), "{at:?} is not {place:?}"); +} + +/// A series' kept unit-box flags are read from and written to a file, and a file without them reads as document +/// coordinates. +#[test] +fn a_series_paints_unit_box_flags_round_trip() { + let kept = vectorcraft_doc::SeriesPaint { fill_unit_box: true, stroke_unit_box: true, ..Default::default() }; + let text = serde_json::to_string(&kept).unwrap(); + assert_eq!(text, r#"{"fillUnitBox":true,"strokeUnitBox":true}"#); + assert_eq!(serde_json::from_str::(&text).unwrap(), kept); + let old: vectorcraft_doc::SeriesPaint = serde_json::from_str("{}").unwrap(); + assert!(!old.fill_unit_box && !old.stroke_unit_box); } /// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command, diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index ae8a7ef6b2..ced9347bc1 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -21,7 +21,7 @@ use vectorcraft_color::{Color, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, Node, NodeId, TextStyleDef}; use super::clipboard::SwatchChoices; -use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, color_value, has_doc, ids_param, index, ok, point_param, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, color_value, has_doc, ids_param, index_opt, ok, point_param, str_param}; use crate::Clipboard; /// The extension of a library file. @@ -1649,11 +1649,11 @@ fn move_group(s: &mut Session, p: &Value) -> Result { const C: &str = "library.moveGroup"; let id = library_id(s, p, C)?; let group = group_param(p, C)?; - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing `to` (an index)"))?; + let to = index_opt(p, "to", C)?.ok_or_else(|| bad(C, "missing `to` (an index)"))?; let (name, at) = s.libraries.change(&id, |lib| { let i = group_at(lib, group, C)?; let g = lib.groups.remove(i); - let at = index(to).min(lib.groups.len()); + let at = to.min(lib.groups.len()); let name = g.name.clone(); lib.groups.insert(at, g); Ok((name, at)) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index f9c4b10049..2383d05a3a 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -88,7 +88,7 @@ pub fn specs() -> Vec { fn artboard_reorder(s: &mut Session, p: &Value) -> Result { const C: &str = "artboard.reorder"; let i = artboard_index(&s.doc()?.doc, p, C)?; - let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing to"))?; + let to = super::index_opt(p, "to", C)?.ok_or_else(|| bad(C, "missing to"))?; s.edit("Reorder Artboards", |d, _| { if i >= d.artboards.len() { return Err(EngineError::Other("no such artboard".into())); @@ -588,6 +588,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "warichuAlign", "akiLeft", "akiRight", + "tsume", "standardVerticalRoman", "noBreak", "digits", @@ -632,6 +633,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let tcy_left_right = super::textedit::tcy_offset_param(p, "tcyLeftRight", C)?; let aki_left = super::textedit::aki_param(p, "akiLeft", C)?; let aki_right = super::textedit::aki_param(p, "akiRight", C)?; + let tsume = super::textedit::tsume_param(p, C)?; let warichu_lines = super::textedit::warichu_lines_param(p, C)?; let warichu_gap = super::textedit::warichu_number_param(p, "warichuGap", C)?; let warichu_scale = super::textedit::warichu_number_param(p, "warichuScale", C)?; @@ -816,6 +818,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = aki_right { st.aki_right = v; } + if let Some(v) = tsume { + st.tsume = v; + } if let Some(v) = flag("standardVerticalRoman") { st.standard_vertical_roman = v; } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 33bde936bf..9d7b42cca2 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -12,7 +12,7 @@ use super::draw2::usize_opt; use super::pathops::Beside; use super::{ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path, - has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets, + has_selection, ids_param, index_opt, number_or, object_param, ok, str_param, switch_or, targets, }; use crate::EngineError; @@ -170,7 +170,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result< fn append_anchor(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = anchor_from_json(p, "path.appendAnchor")?; - let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); + let subpath = index_opt(p, "subpath", "path.appendAnchor")?; s.edit("Pen", |d, sel| { let path = path_mut(d, id)?; let si = match subpath { @@ -208,7 +208,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result { /// `at` first moves the anchor, its handles with it (the Pen's closing drag with Space held). fn close(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?; - let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); + let subpath = index_opt(p, "subpath", "path.close")?; let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) { None => Ok(None), Some(h) => finite_numbers(h).map(|[x, y]| Some(Point::new(x, y))).ok_or_else(|| bad("path.close", format!("`{key}` must be [x, y]"))), @@ -310,7 +310,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge fn set_handle(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?; let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0); - let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?; + let ai = index_opt(p, "anchor", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing anchor"))?; let out = str_param(p, "which") != Some("in"); let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?); let independent = switch_or(p, "independent", false, "path.setHandle")?; @@ -337,7 +337,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result { fn move_anchor(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0); - let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?; + let ai = index_opt(p, "anchor", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?; let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?); // An anchor already at (x, y) changes nothing and records no undo step. if let Some(a) = anchor_of(s, id, si, ai)? @@ -453,7 +453,7 @@ pub(super) fn paths_in(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec Result { const C: &str = "path.reverse"; let want = p.get("reversed").map(|v| v.as_bool().ok_or_else(|| bad(C, "`reversed` must be true or false"))).transpose()?; - let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); + let subpath = index_opt(p, "subpath", C)?; let ids = paths_in(&s.doc()?.doc, &targets(s, p)?); if ids.is_empty() { return Err(bad(C, "select paths")); @@ -1784,7 +1784,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result { fn insert_anchor(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0); - let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?; + let seg = index_opt(p, "segment", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing segment"))?; let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0); let idx = s.edit("Add Anchor Point", |d, sel| { let path = path_mut(d, id)?; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 370664f795..e58f109d20 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -21,7 +21,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind, Shared}; use vectorcraft_geom::Rect; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, object_param, ok, str_param, unique_name}; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index_opt, object_param, ok, str_param, unique_name}; use crate::EngineError; pub fn specs() -> Vec { @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "New Graphic Style", ["Window", "Graphic Styles"], None, - "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}", + "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object, unless it is the art of a graph design in a series. Names are made unique → {name}", has_doc, style_new ), @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. The art of a graph design in a series keeps its look and is unlinked, as the source or as a linked object. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}", has_doc, style_redefine ), @@ -392,8 +392,10 @@ fn style_new(s: &mut Session, p: &Value) -> Result { None => d.new_graphic_style_name(), }; let g = GraphicStyle { id: d.next_graphic_style_id(), ..GraphicStyle::of(name.clone(), captured(n), n) }; + // The art of a graph design in a series is not linked: the graph draws it again from the design. + let link = (!super::graph::is_design_art(d, src)).then_some(g.id); s.edit("New Graphic Style", |d, _| { - set_link(d, &[src], Some(g.id)); + set_link(d, &[src], link); d.graphic_styles.push(g); Ok(()) })?; @@ -491,10 +493,12 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { s.edit_named("Redefine Graphic Style “{name}”", &name, false, |d, _| { let id = d.graphic_style_id(i); let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look }); - // Objects still linked follow the new definition; edited ones are unlinked (they keep - // their look), and the source keeps its own. + // Objects still linked follow the new definition; edited ones are unlinked (they keep their look), and the + // source keeps its own. The art of a graph design in a series is unlinked and keeps its look too, as the + // source or as a linked object: the graph draws it again from the design. let linked = objects_where(d, |n| n.extras().graphic_style == Some(id) && n.id != src); - let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); + let (follow, edited): (Vec, Vec) = + linked.into_iter().partition(|nid| !super::graph::is_design_art(d, *nid) && d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); // A linked graph series group passes the style to the paths directly inside it, so the graph stores it, and // goes back to the empty appearance the graph draws it with. The source and the edited paths keep their look. @@ -509,7 +513,8 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { } super::appearance::capture_series_writes(&before, d, &follow_marks, (true, true), (true, true), false, true); super::graph::capture_label_rows(d, &follow_marks); - set_link(d, &[src], Some(id)); + let link = (!super::graph::is_design_art(d, src)).then_some(id); + set_link(d, &[src], link); Ok(()) })?; Ok(json!({ "name": name })) @@ -585,10 +590,10 @@ fn style_merge(s: &mut Session, p: &Value) -> Result { fn style_move(s: &mut Session, p: &Value) -> Result { let cmd = "graphicStyle.move"; let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?; - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `to` (an index)"))?; + let to = index_opt(p, "to", cmd)?.ok_or_else(|| bad(cmd, "missing `to` (an index)"))?; let d = &s.doc()?.doc; let from = style_index(d, name)?; - let to = index(to).min(d.graphic_styles.len() - 1); + let to = to.min(d.graphic_styles.len() - 1); if from == to { return ok(); } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 0fd7c7d97d..9ed7f3252b 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 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), 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)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), 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 ), @@ -468,6 +468,8 @@ pub(crate) struct CharChange { /// Insert Aki Left and Right, in 1/1000 em. aki_left: Option, aki_right: Option, + /// Tsume, in percent. + tsume: Option, standard_vertical_roman: Option, no_break: Option, digits: Option, @@ -545,6 +547,16 @@ pub(crate) fn aki_param(p: &Value, key: &str, cmd: &str) -> Result> if (lo..=hi).contains(&v) { Ok(Some(v)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } } +/// `tsume` (Tsume): a percentage from 0 to [`vectorcraft_doc::text::MAX_TSUME`]. Any other value is +/// an error. +pub(crate) fn tsume_param(p: &Value, cmd: &str) -> Result> { + let key = "tsume"; + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let v = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?; + let (lo, hi) = (0.0, vectorcraft_doc::text::MAX_TSUME); + if (lo..=hi).contains(&v) { Ok(Some(v)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } +} + /// `digits: "default"|"arabic"|"hindi"|"farsi"|"devanagari"|…` ([`Digits::id`]) → the numeral /// system the digits are drawn in. /// @@ -824,6 +836,7 @@ impl CharChange { warichu_align: warichu_align_param(p, cmd)?, aki_left: aki_param(p, "akiLeft", cmd)?, aki_right: aki_param(p, "akiRight", cmd)?, + tsume: tsume_param(p, cmd)?, standard_vertical_roman: flag("standardVerticalRoman"), no_break: flag("noBreak"), digits: digits_param(p, cmd)?, @@ -888,6 +901,7 @@ impl CharChange { && self.warichu_align.is_none() && self.aki_left.is_none() && self.aki_right.is_none() + && self.tsume.is_none() && self.standard_vertical_roman.is_none() && self.no_break.is_none() && self.digits.is_none() @@ -976,6 +990,9 @@ impl CharChange { if let Some(v) = self.aki_right { st.aki_right = v; } + if let Some(v) = self.tsume { + st.tsume = v; + } if let Some(v) = self.standard_vertical_roman { st.standard_vertical_roman = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index ea1a7a22bd..17d453abf4 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -222,6 +222,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("warichuAlign", json!(v.warichu_align)), ("akiLeft", json!(v.aki_left)), ("akiRight", json!(v.aki_right)), + ("tsume", json!(v.tsume)), ("standardVerticalRoman", json!(v.standard_vertical_roman)), ("noBreak", json!(v.no_break)), ("digits", json!(v.digits)), @@ -578,6 +579,10 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}"))); } } + let (key, lo, hi) = ("tsume", 0.0, vectorcraft_doc::text::MAX_TSUME); + if changed(key) && !(lo..=hi).contains(&st.tsume) { + return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}"))); + } let lines = vectorcraft_doc::text::WARICHU_LINES; if changed("warichuLines") && !lines.contains(&st.warichu_lines) { return Err(bad(&c, format!("`warichuLines` must be a whole number from {} to {}", lines.start(), lines.end()))); @@ -1075,12 +1080,12 @@ fn unused(s: &mut Session, kind: Kind) -> Result { /// The styles of `kind` in `src` that load, with their source names so that art in the file keeps /// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes, -/// scales, leading, shifts, Insert Aki, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose +/// scales, leading, shifts, Insert Aki, Tsume, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose /// indents, spacing, Hyphenation or Justification options or tab stops are out of the range /// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in /// `src` of those skipped) fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) { - const RANGED: [&str; 21] = [ + const RANGED: [&str; 22] = [ "size", "h_scale", "v_scale", @@ -1088,6 +1093,7 @@ fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) { "baseline_shift", "akiLeft", "akiRight", + "tsume", "tcyUpDown", "tcyLeftRight", "warichuLines", diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 3e0727ce4a..c8da4b578c 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -397,6 +397,8 @@ fn character_style_dimensions_are_checked_before_a_definition_changes() { json!({"tcyLeftRight": -1296.1}), json!({"akiLeft": -0.1}), json!({"akiRight": 1000.1}), + json!({"tsume": -0.1}), + json!({"tsume": 100.1}), ] { let key = attrs.as_object().unwrap().keys().next().unwrap(); for cmd in ["charStyle.edit", "charStyle.setAttrs", "charStyle.new"] { @@ -419,7 +421,8 @@ fn character_style_dimensions_are_checked_before_a_definition_changes() { "h_scale": 1.0, "v_scale": 10000.0, "baseline_shift": -1296.0, "tcyUpDown": 1296.0, "tcyLeftRight": -1296.0, - "akiLeft": 0.0, "akiRight": 1000.0 + "akiLeft": 0.0, "akiRight": 1000.0, + "tsume": 100.0 }}), ) .unwrap(); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index ffb6655507..73e2a78b31 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -12,8 +12,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; use super::pathops::{length_given, number_given, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param, - ok, str_param, switch_or, targets, text_param, + CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, index_opt, number_or, + object_param, ok, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -1397,7 +1397,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setPathHandle"; let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0); - let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?; + let ai = index_opt(p, "anchor", C)?.ok_or_else(|| bad(C, "missing anchor"))?; let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in"); let pos = vectorcraft_geom::Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let independent = switch_or(p, "independent", false, C)?; diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 276f40ba45..36a0f965b7 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -1832,6 +1832,61 @@ fn insert_aki_is_a_character_attribute_set_one_side_at_a_time() { assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step for a refused call"); } +/// Tsume (Character panel, East Asian options): a character attribute in percent, 0 by default +/// and saved only when not 0. Both commands set it in one undo step, a call that gives the value +/// the text already has records none, and a value that is not a number or lies outside 0 to 100 is +/// refused, naming the key, before anything changes; a character style captured from the +/// characters carries it. +#[test] +fn tsume_is_a_character_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "(日本)"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + let tsume = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.tsume)).collect::>(); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert!(saved.get("tsume").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"tsume": 50})).unwrap(); + assert_eq!(tsume(&s), [("(日本)".to_string(), 50.0)]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"tsume": 50})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step without a change"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(tsume(&s), [("(日本)".to_string(), 0.0)], "one undo step"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(tsume(&s), [("(日本)".to_string(), 50.0)]); + + let paren = "(".len(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": paren, "tsume": 100})).unwrap(); + assert_eq!(tsume(&s), [("(".to_string(), 100.0), ("日本)".to_string(), 50.0)]); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert_eq!(saved["tsume"], json!(100.0)); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.tsume)).collect(); + assert_eq!(reopened, tsume(&s)); + let attrs = super::super::textstyles::char_attrs(&runs(&s)[0].style); + assert_eq!(attrs["tsume"], json!(100.0)); + + let before = tsume(&s); + let undo = s.doc().unwrap().history.undo.len(); + for (cmd, p, want) in [ + ("text.setFormat", json!({"tsume": "50"}), "`tsume` must be a number"), + ("text.setFormat", json!({"tsume": -1}), "`tsume` must be from 0 to 100"), + ("text.setRangeStyle", json!({"id": id, "tsume": true}), "`tsume` must be a number"), + ("text.setRangeStyle", json!({"id": id, "start": 0, "end": paren, "tsume": 100.5}), "`tsume` must be from 0 to 100"), + ] { + let e = s.execute(cmd, &p).unwrap_err(); + assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == want), "{cmd} {p}: {e}"); + } + assert_eq!(tsume(&s), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step for a refused call"); +} + /// Standard Vertical Roman Alignment (Character panel menu, East Asian options): a character /// attribute, off by default and saved only when on. Documents written before it read as off; a /// character style captured from the characters carries it. diff --git a/crates/engine/src/tests/expand.rs b/crates/engine/src/tests/expand.rs index d853b91110..e4bfa26b2f 100644 --- a/crates/engine/src/tests/expand.rs +++ b/crates/engine/src/tests/expand.rs @@ -422,6 +422,122 @@ fn a_compound_clipping_path_in_the_art_keeps_its_shape() { assert_eq!(seen, (true, true)); } +/// A Drop Shadow 20 pt to the right on row `row` of instance `id`, solid black with no blur. +fn shadow_row(s: &mut Session, id: NodeId, row: usize) { + let params = json!({"x": 20, "y": 0, "blur": 0, "opacity": 100, "mode": "normal"}); + run(s, "effect.apply", json!({"ids": [id.0], "item": row, "effect": "stylize.dropShadow", "params": params})); +} + +/// Is `p` dark: a shadow, not the white page or the red fill? +fn dark(p: [u8; 4]) -> bool { + p[..3].iter().all(|c| *c < 100) +} + +/// A raster effect on a symbol instance's fill draws on the filled shapes of its art: the shadow +/// falls right of the square, the bounds take it in, a hidden fill draws none, and Expand +/// Appearance keeps the look in one undo step. +#[test] +fn a_symbol_instance_fill_shadow_shadows_its_filled_art() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", true); + assert!(near(render(&s).pixel(160, 100), WHITE)); + shadow_row(&mut s, id, row); + let before = render(&s); + assert!(dark(before.pixel(165, 100)), "the fill's shadow falls right of the square: {:?}", before.pixel(165, 100)); + assert!(near(before.pixel(100, 100), RED), "the art draws over its shadow: {:?}", before.pixel(100, 100)); + let doc = s.doc().unwrap().doc.clone(); + let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds"); + assert!(b.x1 >= 169.0, "the bounds take in the shadow: {b:?}"); + // A view that shows only the shadow, right of the square: the instance is still drawn. + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let right = vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::translate((-155.0, 0.0)), &opts); + assert!(dark(right.pixel(10, 100)), "the shadow past the square's box is drawn: {:?}", right.pixel(10, 100)); + + assert!(expandable(&s), "the fill's shadow can be expanded"); + run(&mut s, "effect.expandAppearance", json!({})); + let ch = node(&s, id).children().cloned().expect("the instance becomes a group"); + assert!(matches!(ch[0].kind, NodeKind::Image(_)), "the shadow is an image under the art: {}", ch[0].kind_label()); + assert!(matches!(ch[1].kind, NodeKind::SymbolInstance { .. }), "the art stays an instance: {}", ch[1].kind_label()); + no_effects_left(&ch[1]); + let after = render(&s); + assert!(differing(&before, &after, 24) < 400, "expanding changed {} pixels", differing(&before, &after, 24)); + run(&mut s, "edit.undo", json!({})); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::SymbolInstance { .. }), "one undo gives the instance back"); + assert!(n.appearance.items[row].effects().iter().any(|e| e.id == "stylize.dropShadow")); + + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "visible": false})); + assert!(near(render(&s).pixel(165, 100), WHITE), "a hidden fill draws no shadow"); +} + +/// A raster effect on a symbol instance's stroke draws on the stroked shapes of its art only: the +/// stroke's shadow falls right of the stroke, and none falls where only the fill would cast one. +#[test] +fn a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", false); + shadow_row(&mut s, id, row); + let r = render(&s); + assert!(dark(r.pixel(170, 100)), "the stroke's shadow falls right of the stroke: {:?}", r.pixel(170, 100)); + assert!(near(r.pixel(160, 100), WHITE), "the fill casts no shadow: {:?}", r.pixel(160, 100)); +} + +/// Geometry and raster effects on one fill of an instance: the shadow falls from the grown fill. +#[test] +fn a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", true); + offset_row(&mut s, id, row, 10.0); + shadow_row(&mut s, id, row); + let r = render(&s); + assert!(near(r.pixel(44, 100), RED), "the fill grows by the offset: {:?}", r.pixel(44, 100)); + assert!(dark(r.pixel(175, 100)), "the shadow falls from the grown fill: {:?}", r.pixel(175, 100)); +} + +/// An instance inside a symbol's art passes the outer instance's fill shadow on to its own art. +#[test] +fn a_nested_symbol_instance_takes_the_outer_fill_shadow() { + let mut s = session(); + square(&mut s); + run(&mut s, "symbol.new", json!({"name": "Inner"})); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Outer"}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); + let row = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true})); + shadow_row(&mut s, id, row); + assert!(dark(render(&s).pixel(165, 100)), "the inner art casts the shadow: {:?}", render(&s).pixel(165, 100)); +} + +/// Export draws a symbol instance's fill shadow on the filled shapes of its art: baking leaves the +/// shadow on the art's fill, not its stroke, and PDF export makes an image of it. +#[test] +fn export_draws_a_symbol_instance_fill_shadow() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", true); + shadow_row(&mut s, id, row); + let doc = s.doc().unwrap().doc.clone(); + let baked = vectorcraft_render::effects::bake_document(&doc).expect("the instance is baked"); + let n = baked.node(id).expect("the baked art keeps the instance's id").clone(); + assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. })); + let shadowed = |i: &vectorcraft_doc::AppearanceItem| i.effects().iter().any(|e| e.id == "stylize.dropShadow"); + let (mut fills, mut strokes) = (0, 0); + n.walk(&mut |part| { + fills += part.appearance.items.iter().filter(|i| i.is_fill() && shadowed(i)).count(); + strokes += part.appearance.items.iter().filter(|i| !i.is_fill() && shadowed(i)).count(); + }); + assert_eq!((fills, strokes), (1, 0), "the art's fill carries the shadow"); + + let flat = crate::flatten_raster_effects(&doc).expect("the shadow becomes an image"); + let mut image = None; + flat.node(id).expect("the flattened art keeps the instance's id").walk(&mut |part| { + if matches!(part.kind, NodeKind::Image(_)) { + image = part.geometric_bounds(); + } + }); + let image = image.expect("the shadow is an image"); + assert!(image.x1 >= 165.0, "the image holds the shadow right of the square: {image:?}"); +} + #[test] fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() { let mut s = session(); diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index 8a33376075..52cb314de0 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -849,3 +849,75 @@ fn journal_entries_pass_the_check() { assert!(check_params(id, params).is_ok(), "{id} {params}"); } } + +/// An index of a path, type, panel, library, style or ruler command given as something other than a +/// whole number of 0 or more (`"3"`, `2.5`, `-1`, `true`, `[]`, `{}`) is refused as the wrong type, +/// naming its key. A required one is not reported as missing, and an optional one (`subpath` of +/// `path.appendAnchor`, `path.close` and `path.reverse`, `artboard` of the ruler origin commands) is +/// not read as its default. A required one left out or null is still missing, and an optional one left +/// out or null keeps its default. Neither changes the document or records an undo step. +#[test] +fn a_malformed_index_is_not_missing_or_its_default() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("path.create", &json!({"d": "M0 0 L10 0 M20 0 L30 0"})).unwrap()["id"].clone(); + s.execute("library.create", &json!({"name": "Brand"})).unwrap(); + s.execute("library.createGroup", &json!({"name": "Warm"})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let with = |base: &serde_json::Value, key: &str, v: Option| { + let mut p = base.clone(); + let o = p.as_object_mut().unwrap(); + match v { + Some(v) => o.insert(key.into(), v), + None => o.remove(key), + }; + p + }; + let refused = |s: &mut Session, cmd: &str, p: &serde_json::Value, want: &str| match s.execute(cmd, p) { + Err(EngineError::BadParams { cmd: c, msg }) => assert_eq!((c.as_str(), msg.as_str()), (cmd, want), "{p}"), + other => panic!("{cmd} {p}: {other:?}"), + }; + let malformed = [json!("3"), json!(2.5), json!(-1), json!(true), json!([]), json!({})]; + let handle = json!({"id": id, "x": 5, "y": 5}); + let required = [ + ("path.setHandle", handle.clone(), "anchor", "missing anchor"), + ("path.moveAnchor", handle.clone(), "anchor", "missing anchor"), + ("path.insertAnchor", json!({"id": id}), "segment", "missing segment"), + ("text.setPathHandle", handle.clone(), "anchor", "missing anchor"), + ("artboard.reorder", json!({"index": 0}), "to", "missing to"), + ("library.moveGroup", json!({"group": "Warm"}), "to", "missing `to` (an index)"), + ("graphicStyle.move", json!({"name": "Default"}), "to", "missing `to` (an index)"), + ]; + for (cmd, base, key, missing) in &required { + for v in &malformed { + refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number")); + } + for p in [with(base, key, None), with(base, key, Some(json!(null)))] { + refused(&mut s, cmd, &p, missing); + } + } + let optional = [ + ("path.appendAnchor", handle.clone(), "subpath"), + ("path.close", json!({"id": id}), "subpath"), + ("path.reverse", json!({"ids": [id]}), "subpath"), + ("document.rulerOrigin", json!({}), "artboard"), + ("document.setRulerOrigin", json!({"x": 5, "y": 5}), "artboard"), + ("document.resetRulerOrigin", json!({}), "artboard"), + ]; + for (cmd, base, key) in &optional { + for v in &malformed { + refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number")); + } + } + // A whole number that names no artboard is still out of range. + refused(&mut s, "document.resetRulerOrigin", &json!({"artboard": 1}), "index out of range"); + assert_eq!(s.doc().unwrap().doc, doc); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + // Left out or null, an optional one keeps its default. + for (cmd, base, key) in &optional { + for p in [with(base, key, None), with(base, key, Some(json!(null)))] { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + } + } +} diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 0f58e00181..a3b2800fd3 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -8321,7 +8321,7 @@ impl ArtBrushOptions { /// 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. +/// look; so does a path with the Art brush that keeps its art's colors. #[test] fn brushed_paths_are_read_back_brushed() { let mut doc = brushes_document(); @@ -8341,8 +8341,63 @@ fn brushed_paths_are_read_back_brushed() { 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 { .. })); + assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[2]; + assert!(matches!(n.kind, NodeKind::Path { .. }), "{n:#?}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Plain ribbon")); +} + +/// Art brushes at another width, flipped along or across, scaled proportionately or stretched +/// between guides (flipped along too, or at guides the written numbers round) are written with those +/// options, and a path with each comes back as the path with the brush on its stroke, except Scale +/// Proportionately's, which is written as its brush's art. +#[test] +fn art_brushes_with_options_are_read_back_with_them() { + use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind}; + let thirds = ArtScale::BetweenGuides { start: 1.0 / 3.0, end: 2.0 / 3.0 }; + let options: Vec<(&str, ArtBrush)> = vec![ + ("Wide", ArtBrush { width: 250.0, ..ArtBrush::default() }), + ("Along", ArtBrush { flip_along: true, ..ArtBrush::default() }), + ("Across", ArtBrush { flip_across: true, ..ArtBrush::default() }), + ("Proportional", ArtBrush { scale: ArtScale::Proportional, width: 50.0, ..ArtBrush::default() }), + ("Guides", ArtBrush { scale: ArtScale::BetweenGuides { start: 0.25, end: 0.5 }, ..ArtBrush::default() }), + ("Guides along", ArtBrush { scale: ArtScale::BetweenGuides { start: 0.25, end: 0.5 }, flip_along: true, ..ArtBrush::default() }), + ("Thirds", ArtBrush { scale: thirds, flip_along: true, ..ArtBrush::default() }), + ]; + let lib: Vec = options + .iter() + .map(|(name, a)| Brush { name: (*name).into(), kind: BrushKind::Art(ArtBrush { art: brush_art_node(), ..a.clone() }) }) + .collect(); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + for (i, b) in lib.iter().enumerate() { + let y = 20.0 + 25.0 * i as f64; + brushed(&mut doc, &b.name, (20.0, y), (280.0, y), 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 got = vectorcraft_brush::library(&back); + assert_eq!(got.len(), lib.len()); + for (g, w) in got.iter().zip(&lib) { + let (BrushKind::Art(g), BrushKind::Art(w)) = (&g.kind, &w.kind) else { panic!("art") }; + if w.scale == thirds { + let ArtScale::BetweenGuides { start, end } = g.scale else { panic!("guides: {g:?}") }; + assert!((start - 1.0 / 3.0).abs() < 1e-6 && (end - 2.0 / 3.0).abs() < 1e-6, "{g:?}"); + assert_eq!(ArtBrushOptions::of(&ArtBrush { scale: thirds, ..g.clone() }), ArtBrushOptions::of(w)); + } else { + assert_eq!(ArtBrushOptions::of(g), ArtBrushOptions::of(w)); + } + } + for (n, b) in back.layers[0].children().unwrap().iter().zip(&lib) { + if b.name == "Proportional" { + assert!(matches!(n.kind, NodeKind::Group { .. }), "{}: the brush's art: {n:#?}", b.name); + continue; + } + assert!(matches!(n.kind, NodeKind::Path { .. }), "{}: {n:#?}", b.name); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some(b.name.as_str())); + } } /// A compound path with the Calligraphic brush comes back as that compound path with the brush on its @@ -8646,8 +8701,8 @@ fn brushes_write_the_same_bytes_again() { /// 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 +/// brushes, Art brushes overlapping, with a width that varies, in another direction or with guides +/// that leave no art between them, 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] @@ -8671,13 +8726,10 @@ fn brushes_the_reader_cant_read_are_left_out() { ("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)), + ("guides with no art between them", art_with(&|a| a.scale = ArtScale::BetweenGuides { start: 0.5, end: 0.5 })), ("art tints and shades", art_with(&|a| a.colorization = Colorization::TintsAndShades)), ("art hue shift", art_with(&|a| a.colorization = hue)), ( @@ -8887,8 +8939,8 @@ fn an_empty_brushes_panel_comes_back_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. +/// brush. The new document's 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); @@ -8898,7 +8950,7 @@ fn a_new_documents_brush_on_a_path_writes_the_brushes() { 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 left_out = ["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); diff --git a/crates/eps/src/ai_editing/writer/brushes.rs b/crates/eps/src/ai_editing/writer/brushes.rs index 2c281e702d..e8a7d10b15 100644 --- a/crates/eps/src/ai_editing/writer/brushes.rs +++ b/crates/eps/src/ai_editing/writer/brushes.rs @@ -23,7 +23,7 @@ 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"; +pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't run their art from left to right, that overlap or whose width follows the pen, 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; @@ -145,22 +145,71 @@ fn scatter_params(s: &Scatter, art: &str) -> Option { 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 { +/// An Art brush's settings as its parameters' numbers 2 to 15 give them and a brushed stroke's filter +/// holds them ([`ART_SETTINGS`]): `None` for options the reader doesn't read (it reads an Art brush +/// that runs its art from left to right without overlap, at a fixed width, in Tints or keeping its +/// colors). The guides of Stretch Between Guides are the lengths of the art before the first and +/// after the second, measured before the art is flipped along the path. +fn art_settings(a: &ArtBrush) -> Option<[f64; 14]> { + if a.direction != Direction::LeftToRight || a.width_mode.is_some() || a.overlap { 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]; + let length = a.art.geometric_bounds().map_or(0.0, |b| b.width()); + let (proportional, segmented, before, after) = match a.scale { + ArtScale::Stretch => (0.0, 0.0, 0.0, 0.0), + ArtScale::Proportional => (1.0, 0.0, 0.0, 0.0), + ArtScale::BetweenGuides { start, end } if start < end => { + let (before, after) = (start * length, (1.0 - end) * length); + let (before, after) = if a.flip_along { (after, before) } else { (before, after) }; + (0.0, 1.0, before, after) + } + ArtScale::BetweenGuides { .. } => return None, + }; + let scaling = a.width / 100.0; + let flip = |on: bool| f64::from(u8::from(on)); + Some([ + colorization_code(a.colorization)?, + scaling, + 0.0, + 1.0, + flip(a.flip_along), + flip(a.flip_across), + proportional, + scaling, + scaling, + 0.0, + segmented, + before, + after, + 1.0, + ]) +} + +/// The entries of a brushed stroke's filter that hold an Art brush's settings, in the order of +/// [`art_settings`], with how each is written. +const ART_SETTINGS: [(&str, &str); 14] = [ + ("Colorization", "Int"), + ("Scaling", "Real"), + ("Spacing", "Real"), + ("Adjustment", "Int"), + ("Counterclockwise", "Int"), + ("UpsideDown", "Int"), + ("Proportional", "Bool"), + ("MinScaling", "Real"), + ("MaxScaling", "Real"), + ("ScaleYDependsOn", "Int"), + ("Segmented", "Bool"), + ("StartSegmentLength", "Real"), + ("EndSegmentLength", "Real"), + ("FoldAdjustment", "Bool"), +]; + +/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read +/// ([`art_settings`]). +fn art_params(a: &ArtBrush, art: &str) -> Option { + let settings = art_settings(a)?; + let mut numbers = vec![1.0]; + numbers.extend(settings); Some(format!("2 / {art}/ / / / / 0 0/ 5 0 0 0 / {}", nums6(&numbers))) } @@ -458,25 +507,10 @@ impl Writer<'_> { d.push_str("0 /Int (AngleRelativeTo) ,\n"); "(Calligraphic Brush) /Title ,\n" } - BrushKind::Art(_) => { + BrushKind::Art(a) => { 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}) ,"); + for ((key, kind), value) in ART_SETTINGS.iter().zip(art_settings(a)?) { + let _ = writeln!(d, "{} /{kind} ({key}) ,", nums6(&[value])); } "(Art Brush) /Title ,\n" } diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 5a5db47114..1180e42fa9 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -36,7 +36,13 @@ //! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the //! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the //! rotation follows the path (14), and 1 Tints (1). -//! - Art: 1 Tints (2). +//! - Art: numbers 2 to 15 are the settings a path's art style holds as entries ([`ArtSettings`]): +//! the colorization (2: 0 None, 1 Tints; other codes color the art otherwise), the width as a factor +//! (3), `Spacing` (4), `Adjustment` (5), flipped along (6) and across (7), Scale Proportionately +//! (8), the lowest and highest width when the width follows the pen (9, 10, not drawn otherwise), +//! how the width varies (11, 0 Fixed), Stretch Between Guides (12) and the lengths of the art +//! before its first guide and after its second (13, 14, in the art's units, measured before +//! the art is flipped along the path), and `FoldAdjustment` (15). //! - Pattern: its scale as a factor (3). //! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle //! thickness, density, paint opacity and bristle length as fractions and the size in @@ -45,14 +51,15 @@ //! [`super::read`]), usually in a group after its frame, an unpainted rectangle around it. A frame //! at the art's bounds isn't kept ([`without_frame`]); a wider one is. //! -//! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles +//! What isn't read keeps its default: an Art brush's settings that VectorCraft doesn't draw as the +//! file does ([`art_options`]) apart from its colorization, the pattern brush's tiles //! (which slot is which tile isn't known) and the colorization of the other brushes, which is left //! at None so the art keeps its own colors. use std::collections::{HashMap, HashSet}; use kurbo::Affine; -use vectorcraft_brush::{ArtBrush, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; +use vectorcraft_brush::{ArtBrush, ArtScale, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; use vectorcraft_color::Paint; use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; @@ -118,13 +125,13 @@ pub(super) enum DraftKind { brush: Scatter, art: String, }, - /// The art is the section named `art`. `plain`: the brush stretches its art along the path, - /// unflipped, at 100% in Tints (see [`art_is_plain`]), so [`Self::Art`]'s `brush` draws as the - /// file's brush does. + /// The art is the section named `art`. `settings`: the brush's settings when [`art_options`] + /// reads them, so [`Self::Art`]'s `brush` draws as the file's brush does (its guides set from + /// its art's length by [`finish_brushes`]). Art { brush: ArtBrush, art: String, - plain: bool, + settings: Option, }, /// The five art sections the brush names, in its order (which is which tile isn't known), and /// its scale (%). @@ -226,12 +233,17 @@ pub(super) fn calligraphic(values: [f64; 3], variation: [f64; 3], codes: [f64; 3 Calligraphic { angle, roundness, size, variation, modes: Some(modes) } } +/// An Art brush's settings: its object's numbers 2 to 15, which a path's art style holds as the +/// entries `Colorization`, `Scaling`, `Spacing`, `Adjustment`, `Counterclockwise`, `UpsideDown`, +/// `Proportional`, `MinScaling`, `MaxScaling`, `ScaleYDependsOn`, `Segmented`, +/// `StartSegmentLength`, `EndSegmentLength` and `FoldAdjustment`, in that order. +pub(super) type ArtSettings = [f64; 14]; + /// The settings a path's art style gives its stroke's brush. #[derive(Clone, Debug, PartialEq)] pub(super) enum PathBrush { Calligraphic(Calligraphic), - /// An Art brush whose art stretches along the path, at the brush's own width, in Tints. - Art, + Art(ArtSettings), } /// Does an Art brush draw `art` as the file does? It bends paths in solid colors: other objects @@ -250,8 +262,9 @@ pub(super) fn art_bends(art: &Node) -> bool { bends } -/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does. -pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap) -> HashSet { +/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does, with the +/// length of each (its width, the art running left to right). +pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap) -> HashMap { let names: HashSet<&String> = drafts .iter() .filter_map(|d| match &d.kind { @@ -259,13 +272,15 @@ pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap) -> _ => None, }) .collect(); - names.into_iter().filter(|n| art.get(*n).is_some_and(art_bends)).cloned().collect() + names.into_iter().filter_map(|n| art.get(n).filter(|a| art_bends(a)).and_then(|a| a.geometric_bounds()).map(|b| (n.clone(), b.width()))).collect() } /// Does the brush `draft` draw as `settings`, the settings a path's art style gives it? A path whose /// settings differ (Options of Selected Object) keeps its drawn look: a stroke names a brush, and -/// has no settings of its own. `bending`: [`bending_art`]. -pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashSet) -> bool { +/// has no settings of its own. An Art brush's settings that don't draw ([`drawn`]) may differ, and +/// the path's own settings must be ones the brush would open with ([`art_options`], [`opens_live`]). +/// `bending`: [`bending_art`]. +pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashMap) -> bool { let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0); match (&draft.kind, settings) { (DraftKind::Calligraphic(d), PathBrush::Calligraphic(c)) => { @@ -275,16 +290,90 @@ pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashS && d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b)) && d.modes() == c.modes() } - (DraftKind::Art { plain: true, art: name, .. }, PathBrush::Art) => bending.contains(name), + (DraftKind::Art { settings: Some(d), art: name, .. }, PathBrush::Art(s)) => { + let (dd, ds) = (drawn(d), drawn(s)); + art_options(s).is_some() + && bending.get(name).is_some_and(|length| { + // The guides match as lengths and as fractions of the art's length, so they differ + // neither on short art nor near the ends of long art. + dd.iter().zip(ds).all(|(a, b)| close(*a, b)) + && [11, 12].iter().all(|&i| close(dd[i] / length, ds[i] / length)) + && opens_live(d, *length) + && opens_live(s, *length) + }) + } _ => false, } } -/// Are an Art brush's last 15 numbers those of a brush that stretches its art along the path, -/// unflipped, at 100% in Tints? The numbers aren't read otherwise: any other value keeps its paths' -/// drawn look. -fn art_is_plain(numbers: &[f64; 15]) -> bool { - *numbers == [1.0, 1.0, 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] +/// Art brush settings `s` with those that don't draw set to 0: the lowest and highest width while the +/// width is fixed, and the guides' lengths without Stretch Between Guides. +fn drawn(s: &ArtSettings) -> ArtSettings { + let mut out = *s; + if s[9] == 0.0 { + (out[7], out[8]) = (0.0, 0.0); + } + if s[10] == 0.0 { + (out[11], out[12]) = (0.0, 0.0); + } + out +} + +/// What an Art brush's settings give VectorCraft's Art brush, its guides apart ([`guides`]): `None` +/// when VectorCraft doesn't draw them as the file does: a colorization other than None or Tints, a +/// width outside [`ArtBrush::WIDTH_RANGE`] or one that varies, both Scale Proportionately and Stretch +/// Between Guides, a flip or scale that isn't 0 or 1, or a `Spacing`, `Adjustment` or +/// `FoldAdjustment` other than 0, 1 and 1 (what other values change isn't known). +fn art_options(s: &ArtSettings) -> Option { + let [colorization, scaling, spacing, adjustment, along, across, proportional, _, _, varies, segmented, _, _, fold] = *s; + let switch = |v: f64| match v { + 0.0 => Some(false), + 1.0 => Some(true), + _ => None, + }; + let colorization = match colorization { + 0.0 => Colorization::None, + 1.0 => Colorization::Tints, + _ => return None, + }; + let width = scaling * 100.0; + let (low, high) = ArtBrush::WIDTH_RANGE; + if !(low..=high).contains(&width) || varies != 0.0 || spacing != 0.0 || adjustment != 1.0 || fold != 1.0 { + return None; + } + let scale = match (switch(proportional)?, switch(segmented)?) { + (false, false) => ArtScale::Stretch, + (true, false) => ArtScale::Proportional, + (false, true) => ArtScale::BetweenGuides { start: 0.0, end: 1.0 }, + (true, true) => return None, + }; + Some(ArtBrush { scale, width, flip_along: switch(along)?, flip_across: switch(across)?, colorization, ..ArtBrush::default() }) +} + +/// The guides of Art brush settings `s` (with Stretch Between Guides) for art of length `length`, as +/// [`ArtScale::BetweenGuides`] fractions of the art as it runs along the path: `None` when they leave +/// no art between them. The settings measure the art before it is flipped along the path. +fn guides(s: &ArtSettings, length: f64) -> Option<(f64, f64)> { + let (before, after) = (s[11], s[12]); + if !(before >= 0.0 && after >= 0.0 && before + after < length) { + return None; + } + let (before, after) = if s[4] == 1.0 { (after, before) } else { (before, after) }; + Some((before / length, 1.0 - after / length)) +} + +/// Do Art brush settings `s` draw art of length `length`: no Stretch Between Guides, or guides with +/// art between them? +fn guides_fit(s: &ArtSettings, length: f64) -> bool { + s[10] == 0.0 || guides(s, length).is_some() +} + +/// Does a path with Art brush settings `s`, its art of length `length`, open with the brush on its +/// stroke? Its guides must fit ([`guides_fit`]). Scale Proportionately keeps the drawn look: +/// VectorCraft's Art brush sizes proportional art by the path's length and not by the stroke's +/// weight. +fn opens_live(s: &ArtSettings, length: f64) -> bool { + s[6] == 0.0 && guides_fit(s, length) } /// The brush named `name` of kind `kind` from its parameters `params`. @@ -342,15 +431,22 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< Tool::Art => { // The older form's 8 numbers aren't known: its brush keeps the defaults and its // paths their drawn look. The count tells the form, not the first number. - // Any other count isn't a form this reads. - let (colorization, plain) = if let Ok(n15) = <&[f64; 15]>::try_from(n.as_slice()) { - (if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15)) + // Any other count isn't a form this reads. Settings that aren't read keep the + // defaults but the colorization, and their paths their drawn look. + let (brush, settings) = if let Ok(&[first, ref settings @ ..]) = <&[f64; 15]>::try_from(n.as_slice()) { + match art_options(settings).filter(|_| first == 1.0) { + Some(brush) => (brush, Some(*settings)), + None => { + let colorization = if settings[0] == 1.0 { Colorization::Tints } else { Colorization::None }; + (ArtBrush { colorization, ..ArtBrush::default() }, None) + } + } } else if n.len() == 8 { - (ArtBrush::default().colorization, false) + (ArtBrush::default(), None) } else { return Err(BrushError::Values); }; - DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain } + DraftKind::Art { brush, art: art()?, settings } } _ => { let [_, _, p3, ..] = n.as_slice() else { return Err(BrushError::Values) }; @@ -453,10 +549,15 @@ pub(super) fn finish_brushes_within( _ if !kept => None, DraftKind::Calligraphic(c) => Some(BrushKind::Calligraphic(c)), DraftKind::Scatter { brush, art: name } => copy(&name).map(|a| BrushKind::Scatter(Scatter { art: a, ..brush })), - DraftKind::Art { brush, art: name, .. } => copy(&name).map(|mut a| { + DraftKind::Art { mut brush, art: name, settings } => copy(&name).map(|mut a| { // The side of the section with y up draws on the right of the path, and a brush // draws art's y (down here) on the left of the path. a.transform(Affine::scale_non_uniform(1.0, -1.0), false); + if let ArtScale::BetweenGuides { start, end } = &mut brush.scale { + // Guides that leave no art between them stretch the whole art. + let length = a.geometric_bounds().map_or(0.0, |b| b.width()); + (*start, *end) = settings.and_then(|s| guides(&s, length)).unwrap_or((0.0, 1.0)); + } BrushKind::Art(ArtBrush { art: a, ..brush }) }), DraftKind::Pattern { .. } => None, @@ -586,8 +687,8 @@ pub(crate) struct BrushRead { 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. + /// does ([`art_bends`]) and whose settings VectorCraft draws as the file does ([`art_options`], + /// with guides that leave art between them and without Scale Proportionately: [`opens_live`]). pub live: bool, } @@ -604,7 +705,9 @@ pub(crate) fn read_brush_object(plugin: &str, name: &str, params: &str, art: Opt 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), + DraftKind::Art { settings: Some(s), art: section, .. } => { + bending_art(&drafts, §ions).get(section).is_some_and(|length| opens_live(s, *length)) + } _ => false, }; let [draft] = drafts; @@ -643,8 +746,8 @@ pub(crate) struct BrushesRead { /// 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, + /// The art an Art brush draws as the file does, with its length ([`bending_art`]). + bending: HashMap, } impl BrushesRead { @@ -681,7 +784,7 @@ impl BrushesRead { 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()) + draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art(_)) && !st.effects.is_empty()) } _ => true, }) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index d31ada250c..9d66bd2d17 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -438,8 +438,8 @@ struct BrushesKept { read: (usize, usize), /// Which of [`Reader::brush_drafts`] are kept as brushes. kept: Vec, - /// The art an Art brush draws as the file does. - bending: HashSet, + /// The art an Art brush draws as the file does, with its length. + bending: HashMap, } struct Reader<'a> { diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 911d8340cc..f86ee6603b 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1181,7 +1181,7 @@ mod tests { assert!(nodes > 0); let draft = |i: usize| brush::BrushDraft { name: format!("Brush {i}"), - kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), plain: false }, + kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), settings: None }, }; let drafts: Vec<_> = (0..5).map(draft).collect(); let (kept, left_out) = brush::finish_brushes_within(drafts.clone(), &reader.brush_art, None, 3 * nodes); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 87ba9752ef..f10682b592 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -992,8 +992,8 @@ struct StylePaints<'a> { brushes: &'a [super::super::brush::BrushDraft], /// Which of [`Self::brushes`] are kept as brushes ([`super::super::brush::kept`]). kept: &'a [bool], - /// The art an Art brush draws as the file does ([`super::super::brush::bending_art`]). - bending: &'a HashSet, + /// The art an Art brush draws as the file does, with its length ([`super::super::brush::bending_art`]). + bending: &'a HashMap, space: Affine, } @@ -1270,7 +1270,7 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p if !super::super::brush::draws_as(draft, settings, paints.bending) { return None; } - let art = matches!(settings, super::super::brush::PathBrush::Art); + let art = matches!(settings, super::super::brush::PathBrush::Art(_)); let on_a_path = matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); if !on_a_path && (art || !slot) { return None; @@ -1570,7 +1570,7 @@ mod tests { let st = &styles["Inked"]; let brushes = [parse_tool_object(Tool::Calligraphic, "N", "1 4 8 30 50 -150 210 2 5 6 0").unwrap()]; let (gradients, patterns, names, v7_patterns, bending) = - (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new()); + (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashMap::new()); let paints = StylePaints { gradients: &gradients, patterns: &patterns, @@ -1616,7 +1616,7 @@ mod tests { // GUARDS: an image with a Warp in its art style came in as its drawn look. let warp = ("Adobe Deform", "1 /Int (DeformStyle) ,\n0.4 /Real (DeformValue) ,\n"); // brand-ok: the filter's name in the files let (gradients, patterns, names, v7_patterns, bending) = - (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new()); + (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashMap::new()); let paints = StylePaints { gradients: &gradients, patterns: &patterns, diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index d27a297925..891c058bb6 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -1500,11 +1500,30 @@ const ART_ENTRIES: [&str; 18] = [ "Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files ]; -/// An Art brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a color, names -/// no Art brush, holds an entry that isn't read, or its settings aren't those of art that stretches -/// along the path, unflipped, at the brush's own width (`Scaling` 1), in Tints (`Colorization` 1), -/// with `FoldAdjustment` 1, which is [`vectorcraft_brush::ArtBrush`]'s `overlap: false`: its drawn -/// look stays. +/// The entries of an Art brush's filter that hold its settings, in the order of +/// [`super::brush::ArtSettings`], with how each is written and its value when the filter leaves it +/// out (`None`: the filter must hold it). +const ART_SETTINGS: [(&str, Kind, Option); 14] = [ + ("Colorization", Kind::Int, None), + ("Scaling", Kind::Real, None), + ("Spacing", Kind::Real, Some(0.0)), + ("Adjustment", Kind::Int, Some(1.0)), + ("Counterclockwise", Kind::Int, Some(0.0)), + ("UpsideDown", Kind::Int, Some(0.0)), + ("Proportional", Kind::Bool, Some(0.0)), + ("MinScaling", Kind::Real, Some(1.0)), + ("MaxScaling", Kind::Real, Some(1.0)), + ("ScaleYDependsOn", Kind::Int, Some(0.0)), + ("Segmented", Kind::Bool, Some(0.0)), + ("StartSegmentLength", Kind::Real, Some(0.0)), + ("EndSegmentLength", Kind::Real, Some(0.0)), + ("FoldAdjustment", Kind::Bool, Some(1.0)), +]; + +/// An Art brush's filter `f` as the stroke it paints, with its settings: `None` when it isn't a +/// stroke in a color, names no Art brush, holds an entry that isn't read or a setting that isn't a +/// number of its kind, or tints its art with a swatch's color: its drawn look stays. Whether the +/// brush draws as these settings is [`super::brush::draws_as`]'s to say. fn art_stroke(f: &Filter) -> Option { if !f.paints.0 || f.gradient @@ -1519,28 +1538,13 @@ fn art_stroke(f: &Filter) -> Option { if plugin != ART_BRUSH { return None; } - let off = |key: &str, kind: Kind| matches!(entry(f, key, kind), Ok(None | Some(0.0))); - let plain = off("Proportional", Kind::Bool) - && off("Segmented", Kind::Bool) - && off("UpsideDown", Kind::Int) - && off("Counterclockwise", Kind::Int) - && off("ScaleYDependsOn", Kind::Int) - && off("Spacing", Kind::Real) - && off("StartSegmentLength", Kind::Real) - && off("EndSegmentLength", Kind::Real); - if !plain || entry(f, "Adjustment", Kind::Int).ok()?.unwrap_or(1.0) != 1.0 || entry(f, "FoldAdjustment", Kind::Bool).ok()?.unwrap_or(1.0) != 1.0 { - return None; - } - let one = |key: &str| entry(f, key, Kind::Real).is_ok_and(|v| v.unwrap_or(1.0) == 1.0); - if entry(f, "Scaling", Kind::Real).ok()?? != 1.0 || !one("MinScaling") || !one("MaxScaling") { - return None; - } - if entry(f, "Colorization", Kind::Int).ok()?? != 1.0 { - return None; + let mut settings = [0.0; 14]; + for (slot, (key, kind, missing)) in settings.iter_mut().zip(ART_SETTINGS) { + *slot = entry(f, key, kind).ok()?.or(missing)?; } let paint = f.stroke.clone()?; // The tinted pieces are process colors, so a swatch's ink would be lost. - if matches!(paint, Paint::Solid { swatch: Some(_), .. }) { + if settings[0] == 1.0 && matches!(paint, Paint::Solid { swatch: Some(_), .. }) { return None; } Some(StyleItem::Stroke { @@ -1552,7 +1556,7 @@ fn art_stroke(f: &Filter) -> Option { dash: None, profile: None, align: StrokeAlign::Center, - brush: Some((name.clone(), PathBrush::Art)), + brush: Some((name.clone(), PathBrush::Art(settings))), }) } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 8a9bab7530..7a821bf184 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -9797,7 +9797,7 @@ fn a_pattern_brushs_five_slots_are_kept() { fn an_older_art_brush_comes_in_with_its_art() { const OLD_RIBBON: &str = "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0"; let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", OLD_RIBBON).unwrap(); - assert!(matches!(d.kind, brush::DraftKind::Art { ref art, plain: false, .. } if art == "Ribbon art"), "{d:?}"); + assert!(matches!(d.kind, brush::DraftKind::Art { ref art, settings: None, .. } if art == "Ribbon art"), "{d:?}"); let brush::DraftKind::Art { ref brush, .. } = d.kind else { panic!("art") }; assert_eq!(brush.colorization, vectorcraft_brush::ArtBrush::default().colorization, "the default colorization"); // A count of numbers neither form has isn't read. @@ -9807,8 +9807,8 @@ fn an_older_art_brush_comes_in_with_its_art() { assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"), Err(brush::BrushError::Values)); // 8 numbers are the older form whatever the first number says: read with the defaults. let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", "0 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0").unwrap(); - let brush::DraftKind::Art { ref brush, ref art, plain } = d.kind else { panic!("art") }; - assert_eq!((art.as_str(), plain, brush.colorization), ("Ribbon art", false, vectorcraft_brush::ArtBrush::default().colorization)); + let brush::DraftKind::Art { ref brush, ref art, settings } = d.kind else { panic!("art") }; + assert_eq!((art.as_str(), settings, brush.colorization), ("Ribbon art", None, vectorcraft_brush::ArtBrush::default().colorization)); let objects = [plugin_object(ART_BRUSH, "Ribbon", OLD_RIBBON), plugin_object(ART_BRUSH, "Bare", "1 / / / / / / 0 1 / 1 0 1 0 1 0 0 0")].concat(); let r = read_ok(&format!("{}{objects}{}", brush_art("Ribbon art", 4.0), layer(""))); let lib = vectorcraft_brush::library(&r.doc); @@ -10471,9 +10471,109 @@ fn an_art_brush_bends_only_paths_in_solid_fills() { assert!(wrong.is_empty(), "{wrong:?}"); } +/// The entries of an Art brush's filter that hold its settings, in the order of its object's numbers 2 +/// to 15, with how each is written. +const ART_KEYS: [(&str, &str); 14] = [ + ("Colorization", "Int"), + ("Scaling", "Real"), + ("Spacing", "Real"), + ("Adjustment", "Int"), + ("Counterclockwise", "Int"), + ("UpsideDown", "Int"), + ("Proportional", "Bool"), + ("MinScaling", "Real"), + ("MaxScaling", "Real"), + ("ScaleYDependsOn", "Int"), + ("Segmented", "Bool"), + ("StartSegmentLength", "Real"), + ("EndSegmentLength", "Real"), + ("FoldAdjustment", "Bool"), +]; + +/// The Art brush `Ribbon` (its art 40 long: [`lopsided_art`]) whose settings, numbers 2 to 15 of its +/// object, are those of [`RIBBON`] with `changes` (index in the 14, value), and the style `Inked` that +/// gives a path the same settings. +fn ribbon_with(changes: &[(usize, &str)]) -> (String, String) { + let mut settings = ["1", "1", "0", "1", "0", "0", "0", "1", "1", "0", "0", "0", "0", "1"]; + for (i, v) in changes { + settings[*i] = v; + } + let brush = format!("2 / Ribbon art/ / / / / 0 0/ 5 0 0 0 / 1 {}", settings.join(" ")); + let entries: Vec<(&str, &str, &str)> = ART_KEYS.iter().zip(settings).map(|((key, kind), value)| (*key, value, *kind)).collect(); + (format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", &brush)), art_style("Ribbon", 1.0, &entries, "")) +} + +/// A path with an Art brush whose settings are its own comes in brushed, the brush with those +/// settings: None, another width (the lowest and highest widths aren't drawn while the width is +/// fixed), flipped along or across, and Stretch Between Guides, whose guides come in as fractions of +/// the art as it runs along the path (measured before it is flipped along it, so a flip along swaps +/// them). Lengths without Stretch Between Guides aren't guides. A brush that scales its art +/// proportionately comes into the panel with that option, and its path keeps its drawn look. +#[test] +fn an_art_brush_with_options_comes_in_brushed_with_them() { + use vectorcraft_brush::{ArtBrush, ArtScale, Colorization}; + let check_file = |why: &str, (def, style): (String, String), want: &dyn Fn(&ArtBrush) -> bool| { + let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); + let a = art(&r.doc); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{why}: the path, not its look: {a:#?}"); + assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"), "{why}"); + let lib = vectorcraft_brush::library(&r.doc); + let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("{why}: art") }; + assert!(want(brush), "{why}: {brush:?}"); + r + }; + let check = |why: &str, changes: &[(usize, &str)], want: &dyn Fn(&ArtBrush) -> bool| check_file(why, ribbon_with(changes), want); + let near = |a: f64, b: f64| (a - b).abs() < 1e-9; + // Settings that don't draw may differ between the brush and the path: the lowest and highest + // width while the width is fixed, and lengths without Stretch Between Guides. + let (def, style) = ribbon_with(&[(1, "2"), (7, "2"), (8, "2")]); + let style = style.replace("2 /Real (MinScaling) ,\n", "").replace("2 /Real (MaxScaling) ,\n", ""); + check_file("the path leaves out the widths the pen would give", (def, style), &|b| b.width == 200.0); + let (def, _) = ribbon_with(&[]); + let (_, style) = ribbon_with(&[(11, "5"), (12, "10")]); + check_file("lengths on the path alone", (def, style), &|b| b.scale == ArtScale::Stretch); + check("its art's colors", &[(0, "0")], &|b| b.colorization == Colorization::None); + check("twice as wide", &[(1, "2"), (7, "2"), (8, "2")], &|b| b.width == 200.0); + check("half as wide, the pen's widths left", &[(1, "0.5")], &|b| b.width == 50.0 && b.width_mode.is_none()); + let along = check("flipped along", &[(4, "1")], &|b| b.flip_along && !b.flip_across); + let across = check("flipped across", &[(5, "1")], &|b| b.flip_across && !b.flip_along); + let (def, style) = ribbon_with(&[(6, "1")]); + let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); + assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }), "proportional: the drawn look"); + let lib = vectorcraft_brush::library(&r.doc); + let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("proportional: art") }; + assert_eq!(brush.scale, ArtScale::Proportional); + check( + "between guides", + &[(10, "1"), (11, "5"), (12, "10")], + &|b| matches!(b.scale, ArtScale::BetweenGuides { start, end } if near(start, 5.0 / 40.0) && near(end, 30.0 / 40.0)), + ); + check("between guides, flipped along", &[(10, "1"), (11, "5"), (12, "10"), (4, "1")], &|b| { + b.flip_along && matches!(b.scale, ArtScale::BetweenGuides { start, end } if near(start, 10.0 / 40.0) && near(end, 35.0 / 40.0)) + }); + check("lengths without guides", &[(11, "5"), (12, "10")], &|b| b.scale == ArtScale::Stretch); + // Along a straight line, y down: unflipped, the wide block is above the line and the narrow one + // below it, at the start. + let line = kurbo::BezPath::from_vec(vec![kurbo::PathEl::MoveTo(Point::new(0.0, 0.0)), kurbo::PathEl::LineTo(Point::new(100.0, 0.0))]); + let filled = |r: &Structure, x: f64, y: f64| { + let lib = vectorcraft_brush::library(&r.doc); + let st = art(&r.doc)[0].appearance.stroke().cloned().unwrap(); + let ribbon = lib.iter().find(|b| b.name == "Ribbon").unwrap(); + let pieces = vectorcraft_brush::stroke_pieces(&r.doc, ribbon, &line, &st); + pieces.iter().any(|n| { + let mut hit = false; + n.walk(&mut |c| hit |= c.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), Point::new(x, y)) != 0)); + hit + }) + }; + // At weight 1 the art is 8 across: each block is 4 across, on its side of the line. + assert!(filled(&across, 50.0, 2.0) && filled(&across, 10.0, -2.0) && !filled(&across, 50.0, -2.0), "across: the wide block below"); + assert!(filled(&along, 50.0, -2.0) && filled(&along, 90.0, 2.0) && !filled(&along, 10.0, 2.0), "along: the narrow block at the end"); +} + /// An Art brushed path keeps its drawn look when the brush wouldn't draw it the way the file does: -/// its settings aren't those of art that stretches along the path at the brush's own width and -/// color, the brush's own aren't, its art isn't read, or the brush isn't one the panel keeps. +/// its settings differ from the brush's, the brush's are ones VectorCraft doesn't draw as the file +/// does, its art isn't read, or the brush isn't one the panel keeps. #[test] fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { let good = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); @@ -10516,21 +10616,7 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { ), plain_style.clone(), ), - ( - "a width other than the brush's 100%", - format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", wide)), - art_style( - "Ribbon", - 1.0, - &STRETCHED.map(|e| if matches!(e.0, "Scaling" | "MinScaling" | "MaxScaling") { (e.0, "2", e.2) } else { e }), - "", - ), - ), - ( - "a brush that keeps its art's colors", - format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)), - art_style("Ribbon", 1.0, &STRETCHED.map(|e| if e.0 == "Colorization" { (e.0, "0", e.2) } else { e }), ""), - ), + ("a brush that keeps its art's colors, the path in Tints", format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)), plain_style.clone()), ("an entry that isn't read", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "1 /Int (SomeOtherSetting) ,\n")), ( "an effect on the path", @@ -10543,17 +10629,16 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { ("proportional", good.clone(), style_with("Proportional", "1")), ("between guides", good.clone(), style_with("Segmented", "1")), ("its own width", good.clone(), style_with("Scaling", "2")), - ("a width that varies", good.clone(), style_with("MaxScaling", "2")), ("a width on the pen", good.clone(), style_with("ScaleYDependsOn", "2")), ("spacing", good.clone(), style_with("Spacing", "1")), - ("flipped", good.clone(), style_with("UpsideDown", "1")), + ("a spacing close to the brush's 0", good.clone(), style_with("Spacing", "0.0005")), + ("flipped",good.clone(), style_with("UpsideDown", "1")), ("flipped the other way", good.clone(), style_with("Counterclockwise", "1")), ("another adjustment", good.clone(), style_with("Adjustment", "2")), ("no fold adjustment", good.clone(), style_with("FoldAdjustment", "0")), ("its own colorization", good.clone(), style_with("Colorization", "0")), ("tints and shades", good.clone(), style_with("Colorization", "2")), ("hue shift", good.clone(), style_with("Colorization", "3")), - ("start of a guide", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "5 /Real (StartSegmentLength) ,\n")), ("variable width", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "2 /Int (BSWidthSource) ,\n")), ( "width arrays", @@ -10570,6 +10655,35 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { ("art not read", plugin_object(ART_BRUSH, "Ribbon", RIBBON), plain_style.clone()), ("no such brush", good.clone(), art_style("Other", 1.0, &STRETCHED, "")), ]; + // The brush's own settings, which the path shares, but VectorCraft doesn't draw as the file does. + let own = [ + ("both brush and path: tints and shades", &[(0, "2")][..]), + ("both: hue shift", &[(0, "3")]), + ("both: spacing", &[(2, "1")]), + ("both: another adjustment", &[(3, "2")]), + ("both: no fold adjustment", &[(13, "0")]), + ("both: a width on the pen", &[(9, "1"), (7, "0.5"), (8, "2")]), + ("both: a width past the range", &[(1, "20"), (7, "20"), (8, "20")]), + ("both: a flip of another value", &[(4, "2")]), + ("both: proportional", &[(6, "1")]), + ("both: proportional and between guides", &[(6, "1"), (10, "1"), (11, "5"), (12, "10")]), + ("both: guides that leave no art between them", &[(10, "1"), (11, "30"), (12, "10")]), + ("both: a guide past the start", &[(10, "1"), (11, "-5"), (12, "10")]), + ]; + let mut cases: Vec<(&str, String, String)> = cases + .into_iter() + .chain(own.iter().map(|(why, changes)| { + let (def, style) = ribbon_with(changes); + (*why, def, style) + })) + .collect(); + // The brush's guides leave art between them, and the path's, close to them, leave none. + let (def, style) = ribbon_with(&[(10, "1"), (11, "20"), (12, "19.99")]); + cases.push(("path guides close to the brush's that leave no art between them", def, style.replace("19.99 /", "20 /"))); + // On art 0.01 long, a guide at 0.0035 is 35% of the way along it, and the brush's at 0.003 is 30%. + let (def, style) = ribbon_with(&[(10, "1"), (11, "0.003"), (12, "0.002")]); + let short = format!("%AI8_BeginBrushPattern\n(Ribbon art)\n0 A\n0 g\n{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 0.01, 4.0)); + cases.push(("path guides elsewhere on short art", def.replace(&lopsided_art("Ribbon art"), &short), style.replace("0.003 /", "0.0035 /"))); let mut wrong = vec![]; for (why, def, style) in cases { let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 75a7437e1d..023dbdcbe1 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -92,6 +92,19 @@ struct ImageCode { } impl ImageCode { + /// A hash of everything that is written for the image: equal images have equal keys. + fn content_key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (&self.prelude, &self.dict, self.filter, self.op, &self.ascii).hash(&mut h); + h.finish() + } + + /// Is everything written for `other` the same as for this image? + fn same_image(&self, other: &ImageCode) -> bool { + self.prelude == other.prelude && self.dict == other.dict && self.filter == other.filter && self.op == other.op && self.ascii == other.ascii + } + /// The operators and data that draw the image where it is placed. fn push_inline(&self, s: &mut String) { s.push_str(&self.prelude); @@ -253,6 +266,9 @@ pub(crate) struct Scene<'a> { images: HashMap>, /// The source key and solid paint together identify a colorized encoding. colorized_images: HashMap<(String, String), Option>, + /// Freeform gradients' images by [`ImageCode::content_key`] → their indices in `codes`: a field + /// encoded the same as one already kept draws from it. + fields: HashMap>, /// The encoded images. codes: Vec, /// Where in `body` each placement of an encoded image goes (in order) and the index of its code. @@ -281,6 +297,7 @@ impl<'a> Scene<'a> { brushes: None, images: HashMap::new(), colorized_images: HashMap::new(), + fields: HashMap::new(), codes: vec![], placements: vec![], pending: 0, @@ -478,7 +495,7 @@ impl<'a> Scene<'a> { _ => {} } self.note_losses(n); - let (was, placed) = (self.body.len(), self.placements.len()); + let (was, placed, codes) = (self.body.len(), self.placements.len(), self.codes.len()); match &n.kind { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { children.iter().for_each(|c| self.node(c, false)); @@ -536,6 +553,7 @@ impl<'a> Scene<'a> { if self.size() > self.max_body { self.body.truncate(was); self.unplace(placed); + self.forget_fields(codes); self.warn(TOO_LARGE); } } @@ -1364,6 +1382,14 @@ impl<'a> Scene<'a> { if !(long > 1e-9 && long.is_finite()) { return; } + let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height()); + if !invertible(place) { + return; + } + if self.size().saturating_add(64) > self.max_body { + self.warn(TOO_LARGE); + return; + } let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long); let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16; let (cols, rows) = (along(area.width()), along(area.height())); @@ -1383,9 +1409,43 @@ impl<'a> Scene<'a> { let rgba: Vec = field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect(); self.encode_image(&rgba, w, h) }; - let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height()); - if let Some(image) = image { - self.place_inline(&image, place); + let Some(image) = image else { return }; + let key = image.content_key(); + let found = + self.fields.get(&key).and_then(|bucket| bucket.iter().copied().find(|&i| self.codes.get(i).is_some_and(|c| c.same_image(&image)))); + let i = match found { + Some(i) => i, + None => { + // A field that can't be placed isn't kept. + if self.size().saturating_add(image.inline_len).saturating_add(64) > self.max_body { + self.warn(TOO_LARGE); + return; + } + let i = self.add_code(image); + self.fields.entry(key).or_default().push(i); + i + } + }; + self.place_image(i, place); + } + + /// Forget the field images after the first `n` codes that no placement draws any longer, last + /// first, up to the first code that is drawn or isn't a field image. + fn forget_fields(&mut self, n: usize) { + while self.codes.len() > n { + let i = self.codes.len() - 1; + let Some(code) = self.codes.get(i) else { break }; + if code.uses > 0 { + break; + } + let key = code.content_key(); + let Some(bucket) = self.fields.get_mut(&key) else { break }; + let Some(at) = bucket.iter().position(|&j| j == i) else { break }; + bucket.swap_remove(at); + if bucket.is_empty() { + self.fields.remove(&key); + } + self.codes.pop(); } } @@ -1595,21 +1655,6 @@ impl<'a> Scene<'a> { self.line("Q"); } - /// Draw `code`, an image drawn nowhere else, on the unit square mapped by `place`. - fn place_inline(&mut self, code: &ImageCode, place: Affine) { - if !invertible(place) { - return; - } - if self.size().saturating_add(code.inline_len).saturating_add(64) > self.max_body { - self.warn(TOO_LARGE); - return; - } - self.line("q"); - let _ = writeln!(self.body, "{} cm", ps::matrix(place)); - code.push_inline(&mut self.body); - self.line("Q"); - } - /// Straight RGBA pixels (`w` × `h`, rows from the top) as the operators and data that draw /// them on the unit square: in the process colour space, transparent pixels masked out at /// Level 3 (a key colour no opaque pixel has), white at Level 2; partly transparent ones over diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 2ab96d3555..e9d6f79521 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -1264,6 +1264,104 @@ fn the_page_limit_counts_a_repeated_images_samples_once() { assert!(page.body.len() + page.setup.len() <= inline + 1000 + 400, "{}", page.body.len()); } +/// A freeform gradient of `points` (x, y and color). +fn freeform(points: &[(f64, f64, Color)]) -> Paint { + let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() }); + g.freeform = Some(Freeform { points: points.iter().map(|&(x, y, c)| FreeformPoint::new(Point::new(x, y), c)).collect(), ..Default::default() }); + Paint::Gradient(Box::new(g)) +} + +/// A square at (`x`, `y`) of side `side`, filled with a freeform gradient of `a` and `b` at a +/// fifth and four fifths of its width. +fn freeform_square(d: &mut Document, x: f64, y: f64, side: f64, a: Color, b: Color) -> Node { + let mid = y + side / 2.0; + let paint = freeform(&[(x + side / 5.0, mid, a), (x + side * 0.8, mid, b)]); + path(d, shapes::rectangle(Rect::new(x, y, x + side, y + side)), paint, Paint::None, 0.0) +} + +/// A freeform gradient on a shape and on a moved copy of it is sampled the same for both: its +/// image's samples are written once, at both levels, in RGB and in CMYK. +#[test] +fn a_freeform_gradients_image_drawn_twice_writes_its_samples_once() { + let rgb = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + let cmyk = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); + for (a, b) in [rgb, cmyk] { + let single = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, a, b)]); + let twice = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, a, b), freeform_square(d, 64.0, 64.0, 64.0, a, b)]); + for level in Level::ALL { + let one = export(&single, &EpsOptions { level, ..opts(&single) }, None, None).unwrap(); + let one_ps = text(&one); + assert!(!one_ps.contains("VCim"), "a field drawn once is written where it is drawn: {one_ps}"); + let out = export(&twice, &EpsOptions { level, ..opts(&twice) }, None, None).unwrap(); + let ps = text(&out); + checked_page(&ps); + assert_eq!(reuses(&ps, 0, level), 2, "{ps}"); + assert!(!ps.contains("VCim1"), "{ps}"); + let body = &ps[ps.find("%%EndSetup").unwrap()..]; + assert!(!body.contains("currentfile") && !body.contains("~>"), "no samples in the page: {ps}"); + assert_eq!(reused_samples(&ps, level), inline_samples(&one_ps, level), "{level:?}"); + assert_eq!(out.warnings, one.warnings); + } + } +} + +/// Fields sampled differently are each written where they are drawn: different gradients, and one +/// gradient on shapes of different sizes. +#[test] +fn freeform_gradients_sampled_differently_dont_share_an_image() { + let (red, blue, green) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)); + let colors = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, green)]); + let sizes = doc_with(|d| { + let paint = freeform(&[(20.0, 50.0, red), (80.0, 50.0, blue)]); + vec![ + path(d, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint.clone(), Paint::None, 0.0), + path(d, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 100.0)), paint, Paint::None, 0.0), + ] + }); + for d in [colors, sizes] { + for level in Level::ALL { + let ps = page_of(&d, &EpsOptions { level, ..opts(&d) }); + assert!(!ps.contains("VCim"), "{ps}"); + assert_eq!(ps.matches("} exec\n").count(), 2, "{ps}"); + } + } +} + +/// A field's second placement that passes the page limit is left out, and the first is written +/// where it is drawn. A field taken back with the shape that passed the limit leaves nothing in the +/// setup. +#[test] +fn a_field_taken_back_at_the_page_limit_leaves_no_image_behind() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + let single = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue)]); + let o = opts(&single); + let inline = crate::scene::Scene::new(&single, &o).run().body; + let twice = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, blue)]); + let page = crate::scene::Scene::new(&twice, &o).with_max_body(inline.len() + 50).run(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{:?}", page.warnings); + assert!(page.body.starts_with(&inline), "{}", page.body); + assert_eq!(page.body.matches("} exec\n").count(), 1, "{}", page.body); + assert!(!page.setup.contains("VCim"), "{}", page.setup); + // A many-sided shape whose stroke passes the limit after its fill's field was placed. + let big = |d: &mut Document, stroke: Paint| { + let paint = freeform(&[(60.0, 100.0, Color::rgb(0.0, 1.0, 0.0)), (140.0, 100.0, red)]); + path(d, shapes::polygon(Point::new(100.0, 100.0), 90.0, 2000, 0.0), paint, stroke, 1.0) + }; + let filled = doc_with(|d| vec![big(d, Paint::None)]); + let stroked = doc_with(|d| vec![big(d, Paint::solid(Color::rgb(0.0, 0.0, 0.0)))]); + let fill = crate::scene::Scene::new(&filled, &o).run().body.len(); + let both = crate::scene::Scene::new(&stroked, &o).run().body.len(); + assert!(both > fill + 20_000, "{fill} {both}"); + let d = doc_with(|d| { + let s = big(d, Paint::solid(Color::rgb(0.0, 0.0, 0.0))); + vec![s, freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, blue)] + }); + let page = crate::scene::Scene::new(&d, &o).with_max_body(fill + (both - fill) / 2).run(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{:?}", page.warnings); + assert_eq!(reuses(&page.body, 0, Level::Three), 2, "{}", page.body); + assert!(page.setup.contains("/VCim0 VCsrc") && !page.setup.contains("VCim1"), "{}", page.setup); +} + #[test] fn run_length_and_ascii85_round_trip() { let cycle: Vec = (0..=255u8).cycle().take(1000).collect(); diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index f22b9dfd41..a6bc021fdc 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1330,7 +1330,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.exportAs", "Export As…", "", - "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it", + "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it", ), ( "ui.fileInfoDialog", diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 13a6dad415..ce76713e74 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -356,7 +356,7 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { }; let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_ref()); let b = match &reshaped { - Some(r) if matches!(n.kind, NodeKind::SymbolInstance { .. }) && effects::has_item_geometry(n) => instance_piece_bounds(symbols, r), + Some(r) if has_instance_row_effects(n) => instance_piece_bounds(symbols, r), Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) } @@ -379,9 +379,16 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { } } +/// Is `n` a symbol instance whose active fill or stroke has geometry or raster effects that the +/// fills and strokes of its art take ([`effects::has_item_geometry`], +/// [`effects::has_instance_row_raster`])? +fn has_instance_row_effects(n: &Node) -> bool { + matches!(n.kind, NodeKind::SymbolInstance { .. }) && (effects::has_item_geometry(n) || effects::has_instance_row_raster(n)) +} + /// The bounds of `art`, the art [`effects::reshape`] made of a symbol instance whose active fill or -/// stroke has geometry effects ([`effects::has_item_geometry`]): its paths measured with the -/// geometry effects of their fills and strokes ([`visual_bounds`]), the symbol instances in it that +/// stroke has geometry or raster effects ([`has_instance_row_effects`]): its paths measured with the +/// effects of their fills and strokes, shadows and glows included ([`visual_bounds`]), the symbol instances in it that /// took those effects by their own reshaped art in turn, its groups and layers with their own fills /// and strokes by the art those paint ([`effects::evaluate_container`]), its clip groups by their /// clipping paths and other objects as [`Node::visual_bounds_with`] measures them. Hidden objects are left out. @@ -409,7 +416,7 @@ fn pieces_within(symbols: &SymbolBoxes, art: &Node, depth: usize, budget: &mut u g.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, pieces_within(symbols, c, depth, budget))) } NodeKind::Path { .. } | NodeKind::Compound { .. } if has_fx(art) => visual_bounds(symbols, art), - NodeKind::SymbolInstance { symbol, .. } if depth < vectorcraft_doc::depth::MAX_DEPTH && effects::has_item_geometry(art) => { + NodeKind::SymbolInstance { symbol, .. } if depth < vectorcraft_doc::depth::MAX_DEPTH && has_instance_row_effects(art) => { let doc = symbols.document(); let reshaped = doc.symbols.iter().find(|s| s.name == *symbol).and_then(|s| { let count = s.art.count() as u64; @@ -639,7 +646,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b }; let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_ref()); let b = match &reshaped { - Some(r) if matches!(n.kind, NodeKind::SymbolInstance { .. }) && effects::has_item_geometry(n) => instance_piece_bounds(symbols, r), + Some(r) if has_instance_row_effects(n) => instance_piece_bounds(symbols, r), Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) } diff --git a/crates/render/src/fx/predicates.rs b/crates/render/src/fx/predicates.rs index 4f14cadfd1..e4e479b2e5 100644 --- a/crates/render/src/fx/predicates.rs +++ b/crates/render/src/fx/predicates.rs @@ -20,12 +20,14 @@ pub(crate) fn has_fx(n: &Node) -> bool { /// Does `n` carry visible object-level effects that only apply through its art (type, images, /// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with /// a geometry effect on a fill or stroke of its own or an image with a fill or stroke of its own -/// that paints a shape ([`effects::has_item_geometry`]), is it a path whose Transform effect copies -/// it with a gradient or changes its fills and strokes ([`effects::has_transform_art`]), or is it a -/// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])? +/// that paints a shape ([`effects::has_item_geometry`]), a symbol instance whose active fill or +/// stroke has a raster effect ([`effects::has_instance_row_raster`]), is it a path whose Transform +/// effect copies it with a gradient or changes its fills and strokes ([`effects::has_transform_art`]), +/// or is it a group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])? pub(crate) fn has_object_fx(n: &Node) -> bool { (effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some())) || effects::has_item_geometry(n) + || effects::has_instance_row_raster(n) || effects::has_transform_art(n) || effects::has_container_appearance(n) || effects::has_crop_marks(n) diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 7708d23c06..36744c8de5 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -31,6 +31,8 @@ image = { workspace = true } # Colour / transform / length parsing for the fallback (MIT OR Apache-2.0; already used by usvg). svgtypes = "0.16" simplecss = "0.2.2" +# Bidi classes for the first strong character of `unicode-bidi: plaintext` text (MIT OR Apache-2.0; already used by vectorcraft-text). +unicode-bidi = "0.3.18" [dev-dependencies] # Reading embedded font subsets back. diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index 2b9139f19b..ccd5f56806 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -283,6 +283,11 @@ impl Writer<'_> { self.warn("type on a path with Insert Aki is outlined to keep its look"); return self.text_outlines(n, t); } + // Tsume does as well. + if t.runs.iter().any(|r| r.style.tsume != 0.0) { + self.warn("type on a path with Tsume is outlined to keep its look"); + return self.text_outlines(n, t); + } // A `` holds the characters of the text, not the bullets or numbers drawn // before them. if !lay.markers.is_empty() { @@ -715,13 +720,13 @@ const PLACE_TOLERANCE: f64 = 1e-6; /// starts a new segment at each character the layout draws elsewhere than a reader continuing /// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between /// the characters of a justified Japanese line, and the spacing Mojikumi changes. Text with Insert -/// Aki does so on every line, with `fewer` too. +/// Aki or Tsume does so on every line, with `fewer` too. fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec { let plain = t.plain_text(); let mut lines = Vec::with_capacity(lay.lines.len()); let (mut kerned, mut unkerned) = (None, None); - // Insert Aki moves characters from where a reader draws them, with `fewer` too. - let aki = t.runs.iter().any(|r| r.style.aki_left != 0.0 || r.style.aki_right != 0.0); + // Insert Aki and Tsume move characters from where a reader draws them, with `fewer` too. + let aki = t.runs.iter().any(|r| r.style.aki_left != 0.0 || r.style.aki_right != 0.0 || r.style.tsume != 0.0); for line in &lay.lines { let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count()); let style = t.para_at(para); @@ -885,11 +890,12 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: if !kerning { r.style.kerning.get_or_insert(0.0); } - // A reader puts no space before a line's first character (`kerningBefore`) and no Insert - // Aki: the segment starts that much further along instead. + // A reader puts no space before a line's first character (`kerningBefore`), no Insert + // Aki and no Tsume: the segment starts that much further along instead. r.style.kerning_before = 0.0; r.style.aki_left = 0.0; r.style.aki_right = 0.0; + r.style.tsume = 0.0; } let relaid; let glyphs = if set == *t { diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 1a221a69f1..87e100f1b9 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -711,9 +711,9 @@ struct Ctx { sizes: RefCell>, weights: RefCell>, elems: RefCell>>, - /// Whether the first strong character of each element read so far is right to left - /// ([`Self::first_strong_rtl`]); `None` where it holds none. - strong: RefCell>>, + /// The first strong characters of each `` read so far, by the `` + /// ([`Self::first_strong_rtl`]). + strong: RefCell>>, /// The path each `` read so far follows ([`Self::text_path`]); `None` where it has /// no usable one. The styles of a `` instance share it, so each path is found and parsed /// once however many `` elements show its text. @@ -1006,22 +1006,39 @@ impl Ctx { } /// Whether the first strong character (UAX #9 P2) of the characters [`Self::collect`] reads - /// from element `n` is right to left; `None` when they hold none. Each element is read once. + /// from element `n` is right to left; `None` when they hold none. The characters of the whole + /// `` are read once, as one sequence, so an isolate that opens in one text node and + /// closes in a later one is skipped whole ([`FirstStrong`]). fn first_strong_rtl(&self, n: XNode) -> Option { - if let Some(s) = self.strong.borrow().get(&n.id()) { - return *s; + // The `` (or outermost element) whose characters hold those of `n`. + let mut root = n; + while let Some(p) = root.parent_element().filter(|_| root.tag_name().name() != "text" && self.is_inline_child(root)) { + root = p; } - let s = n.children().find_map(|c| { - if c.is_text() { - text_first_strong_rtl(c.text().unwrap_or("")) - } else if self.is_inline_child(c) { - self.first_strong_rtl(c) - } else { - None - } + let strong = self.strong.borrow().get(&root.id()).cloned(); + let strong = strong.unwrap_or_else(|| { + let mut chars = vec![]; + let mut spans = HashMap::new(); + self.inline_chars(root, &mut chars, &mut spans); + let s = Rc::new(FirstStrong::new(&chars, spans)); + self.strong.borrow_mut().insert(root.id(), s.clone()); + s }); - self.strong.borrow_mut().insert(n.id(), s); - s + strong.of(n.id()) + } + + /// Appends the characters of element `n` and of its shown inline children to `chars`, in + /// document order, and the range each of those elements holds to `spans`. + fn inline_chars(&self, n: XNode, chars: &mut Vec, spans: &mut HashMap>) { + let start = chars.len(); + for c in n.children() { + if c.is_text() { + chars.extend(c.text().unwrap_or("").chars()); + } else if self.is_inline_child(c) { + self.inline_chars(c, chars, spans); + } + } + spans.insert(n.id(), start..chars.len()); } /// Whether child `c` of a `` content element holds characters of the text: a shown @@ -1774,6 +1791,56 @@ fn text_first_strong_rtl(text: &str) -> Option { (!vectorcraft_text::paragraph_is_rtl(&format!("{line}\u{5D0}"), None)).then_some(false) } +/// The first strong characters (UAX #9 P2) of the elements of one ``, found for each element +/// in constant time from one pass over the text's characters. An isolate initiator is skipped to +/// its matching PDI (UAX #9 BD9), and one without a matching PDI to the end; paragraph separators +/// count as spaces, as in [`text_first_strong_rtl`]. +struct FirstStrong { + /// The characters each element holds: a range of indices into `next`. + spans: HashMap>, + /// For each character and for the end: the index of the first strong character from there on, + /// isolates skipped, and whether it is right to left; `None` when there is none. + next: Vec>, +} + +impl FirstStrong { + fn new(chars: &[char], spans: HashMap>) -> Self { + use unicode_bidi::BidiClass::{AL, FSI, L, LRI, PDI, R, RLI}; + let class = |c: char| unicode_bidi::bidi_class(c); + // The matching PDI of each isolate initiator. + let mut pdi = vec![None; chars.len()]; + let mut open = vec![]; + for (i, &c) in chars.iter().enumerate() { + match class(c) { + LRI | RLI | FSI => open.push(i), + PDI => { + if let Some(at) = open.pop() { + pdi[at] = Some(i); + } + } + _ => {} + } + } + let mut next = vec![None; chars.len() + 1]; + for (i, &c) in chars.iter().enumerate().rev() { + next[i] = match class(c) { + L => Some((i, false)), + R | AL => Some((i, true)), + LRI | RLI | FSI => pdi[i].and_then(|p| next[p + 1]), + _ => next[i + 1], + }; + } + Self { spans, next } + } + + /// Whether the first strong character of element `n` is right to left; `None` when it holds + /// none. An isolate that opens inside `n` and closes after it hides the rest of `n`. + fn of(&self, n: roxmltree::NodeId) -> Option { + let span = self.spans.get(&n)?; + self.next.get(span.start).copied().flatten().filter(|&(at, _)| at < span.end).map(|(_, rtl)| rtl) + } +} + /// A left-aligned point text of `runs` at the origin (for measuring). fn with_runs(runs: Vec) -> TextObject { let mut t = TextObject::point(Point::ZERO, "", CharStyle::default()); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 472d7517e4..0bc8c34dfd 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -587,6 +587,12 @@ fn unicode_bidi_values_the_text_model_cannot_hold_are_reported() { (r#"אבab"#.to_string(), "ab", Ltr), (format!("{far}"), "\u{5D0}", Rtl), (format!("\n12 {far}"), "\u{5D0}", Rtl), + // An isolate that opens in one text node and closes in a later one is skipped whole. + ("⁧ab⁩א".to_string(), "\u{5D0}", Rtl), + ("⁧abcd⁩א".to_string(), "\u{5D0}", Rtl), + // An isolate that opens in a nested `plaintext` element and closes after it hides the rest of that element. + (r#"⁧ab⁩א"#.to_string(), "\u{5D0}", Rtl), + (r#"⁧ab⁩א"#.to_string(), "ab", Ltr), ] { let (d, _) = import_with_report(&svg(&format!(r#"x {body}"#))).unwrap(); diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index 8dd5fe4bac..c53318c358 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -196,6 +196,35 @@ fn insert_aki_places_each_moved_character() { assert!(!out.svg.contains("` where the character is +/// drawn, so a reader draws the text where the canvas does, with fewer ``s asked for too. +/// On a path the type is outlined. +#[test] +fn tsume_places_each_moved_character() { + let tight = CharStyle { tsume: 50.0, ..style(12.0) }; + let runs = + vec![run("H", style(12.0)), run("O", tight.clone()), run("H ", style(12.0)), run("OHO\nHO", CharStyle { tsume: 100.0, ..style(12.0) })]; + for justify in [Justify::Left, Justify::Center, Justify::Right] { + assert_drawn_in_place(&area_doc(runs.clone(), 200.0, justify)); + } + let d = area_doc(runs, 200.0, Justify::Left); + let plain = tspans(&export(&area_doc(vec![run("HOH OHO\nHO", style(12.0))], 200.0, Justify::Left), &ExportOptions::default())).len(); + let fewer = tspans(&export(&d, &ExportOptions { fewer_tspans: true, ..Default::default() })).len(); + assert!(fewer > plain, "each moved character is placed with fewer tspans too: {plain} {fewer}"); + let mut t = TextObject::point(Point::ZERO, "", style(12.0)); + t.runs = vec![run("HO", tight)]; + let mut line = BezPath::new(); + line.move_to((20.0, 100.0)); + line.line_to((280.0, 100.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = vectorcraft_svg::export_full(&d, &ExportOptions::default(), None); + assert!(!out.svg.contains(" bool { kerning_before: _, aki_left: _, aki_right: _, + tsume: _, optical_kerning, baseline_shift, h_scale, diff --git a/crates/text/src/layout/shaped.rs b/crates/text/src/layout/shaped.rs index 72f6ed8733..63005364fb 100644 --- a/crates/text/src/layout/shaped.rs +++ b/crates/text/src/layout/shaped.rs @@ -5,6 +5,11 @@ //! line. Each line the composer tries is placed from where the lines above it left the pen, at its //! own tallest metrics, as the line loop in [`super::flow`] places a composed line: the span it //! gets is the room across its whole height. +//! +//! A paragraph the frame cannot hold is still composed whole: a line the frame has no room for, +//! and every line after it, is measured as wide as the span of the last line set in the frame, +//! less its own indents. The line loop sets only the lines that fit, and the rest overflows or +//! goes on in the next threaded frame, which composes it again at its own measures. use std::cell::Cell; use std::collections::HashMap; @@ -63,19 +68,33 @@ struct Start<'r> { /// The paragraph's first line (it takes the first-line indent). first: bool, pen: Pen<'r>, - /// The line placed at each of the metrics it was tried with: its width and the pen after it, - /// or `None` when the frame has no room for it. - placed: Vec<(Metrics, Option<(f64, Pen<'r>)>)>, + /// The width of the span the line above was set in, `None` for the paragraph's first line. + span: Option, + /// The line above was past the frame's end: this line is too, at the width of `span`. + past: bool, + /// The line placed at each of the metrics it was tried with, or `None` when it can't be set. + placed: Vec<(Metrics, Option>)>, /// The metrics of glyphs `pos..to`, measured so far. to: usize, m: Option, } -/// What a line's placement depends on: its first glyph, whether it is the paragraph's first line, -/// and the pen's cell, baselines, pending space, narrowest word without room and further spans. -type Key = (usize, bool, usize, Option, Option, u64, u64, Vec<[u64; 3]>); +/// A line set from a [`Start`]: its width, the pen after it, the width of the span it was set +/// in, and whether it is past the frame's end (measured, but not in the frame). +#[derive(Clone)] +struct Placed<'r> { + width: f64, + pen: Pen<'r>, + span: f64, + past: bool, +} -fn key(pos: usize, first: bool, pen: &Pen<'_>) -> Key { +/// What a line's placement depends on: its first glyph, whether it is the paragraph's first line, +/// the pen's cell, baselines, pending space, narrowest word without room and further spans, the +/// span of the line above and whether the line is past the frame's end. +type Key = (usize, bool, usize, Option, Option, u64, u64, Vec<[u64; 3]>, Option, bool); + +fn key(pos: usize, first: bool, pen: &Pen<'_>, span: Option, past: bool) -> Key { ( pos, first, @@ -85,6 +104,8 @@ fn key(pos: usize, first: bool, pen: &Pen<'_>) -> Key { pen.pending.to_bits(), pen.no_room_for.to_bits(), pen.queued.iter().map(|&(b, x0, x1)| [b.to_bits(), x0.to_bits(), x1.to_bits()]).collect(), + span.map(f64::to_bits), + past, ) } @@ -159,13 +180,14 @@ impl<'a, 'r> Shaped<'a, 'r> { } } - /// The start of a line at glyph `pos` with `pen`: an existing one when its placement is the same. - fn start(&mut self, pos: usize, first: bool, pen: Pen<'r>) -> usize { - let k = key(pos, first, &pen); + /// The start of a line at glyph `pos` with `pen`, after a line set in a span `span` wide (past + /// the frame's end with `past`): an existing one when its placement is the same. + fn start(&mut self, pos: usize, first: bool, pen: Pen<'r>, span: Option, past: bool) -> usize { + let k = key(pos, first, &pen, span, past); if let Some(&id) = self.known.get(&k) { return id; } - self.starts.push(Start { pos, first, pen, placed: vec![], to: pos, m: None }); + self.starts.push(Start { pos, first, pen, span, past, placed: vec![], to: pos, m: None }); let id = self.starts.len() - 1; self.known.insert(k, id); id @@ -177,7 +199,7 @@ impl Measure for Shaped<'_, '_> { fn first(&mut self) -> usize { let pen = self.pen.clone(); - self.start(0, !self.continued, pen) + self.start(0, !self.continued, pen, None, false) } fn line(&mut self, &id: &usize, pos: usize, end: usize) -> Result, Exhausted> { @@ -186,14 +208,14 @@ impl Measure for Shaped<'_, '_> { } self.probes += 1; let Some(start) = self.starts.get_mut(id) else { return Ok(None) }; - let Some((width, pen)) = line(&self.para, start, pos, end)? else { return Ok(None) }; - Ok(Some((width, self.start(end, false, pen)))) + let Some(placed) = line(&self.para, start, pos, end)? else { return Ok(None) }; + Ok(Some((placed.width, self.start(end, false, placed.pen, Some(placed.span), placed.past)))) } /// A longer line is placed as this one is, and gets the same room, unless a glyph after it - /// changes the line's tallest metrics. + /// changes the line's tallest metrics. Past the frame's end every line is as wide. fn may_widen(&mut self, &id: &usize, pos: usize, end: usize) -> bool { - let Some(start) = self.starts.get(id) else { return false }; + let Some(start) = self.starts.get(id).filter(|start| !start.past) else { return false }; let m = if start.pos == pos && start.to == end { start.m } else { self.para.sg.get(pos..end).and_then(Metrics::max) }; match (m, self.rest.get(end).copied().flatten()) { (Some(m), Some(rest)) => m.union(rest) != m, @@ -212,12 +234,14 @@ impl Drop for Shaped<'_, '_> { } } -/// The width of the line of glyphs `pos..end` from `start`, and the pen after it; `None` when -/// the frame has no room for it. -fn line<'r>(p: &Para<'_>, start: &mut Start<'r>, pos: usize, end: usize) -> Result)>, Exhausted> { +/// The line of glyphs `pos..end` from `start`; `None` when it can't be set. +fn line<'r>(p: &Para<'_>, start: &mut Start<'r>, pos: usize, end: usize) -> Result>, Exhausted> { if start.pos != pos || end <= pos { return Ok(None); } + if start.past { + return Ok(past_end(p, start)); + } // The line's tallest metrics, measured on from the glyphs already measured. if end < start.to { start.m = p.sg.get(pos..end).and_then(Metrics::max); @@ -239,9 +263,18 @@ fn line<'r>(p: &Para<'_>, start: &mut Start<'r>, pos: usize, end: usize) -> Resu Ok(placed) } -/// The line from `start` placed with metrics `m`: its width and the pen after it, as the line loop -/// places and settles it; `None` when no frame cell has room for it. -fn place<'r>(p: &Para<'_>, start: &Start<'r>, m: Metrics) -> Result)>, Exhausted> { +/// A line from `start` past the frame's end: as wide as the span of the last line set in the frame, +/// less its indents, and the pen left where the frame ran out. `None` when the paragraph has no line +/// in the frame to measure it by. +fn past_end<'r>(p: &Para<'_>, start: &Start<'r>) -> Option> { + let span = start.span.filter(|span| span.is_finite())?; + let (ind_l, ind_r) = line_indents(p.para, start.first, p.mirror); + Some(Placed { width: span - ind_l - ind_r, pen: start.pen.clone(), span, past: true }) +} + +/// The line from `start` placed with metrics `m`, as the line loop places and settles it; past the +/// frame's end ([`past_end`]) when no frame cell has room for it. +fn place<'r>(p: &Para<'_>, start: &Start<'r>, m: Metrics) -> Result>, Exhausted> { let (ind_l, ind_r) = line_indents(p.para, start.first, p.mirror); let word = word_width(p.sg, start.pos, p.line_end, p.para.kinsoku) - (p.start_credit)(start.pos); let mut pen = start.pen.clone(); @@ -262,12 +295,12 @@ fn place<'r>(p: &Para<'_>, start: &Start<'r>, m: Metrics) -> Result return Ok(None), // Out of steps: the frame may still have room further down. None if p.steps.get() == 0 => return Err(Exhausted), - None => return Ok(None), + None => return Ok(past_end(p, start)), } } } diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index ff79ce9a4c..befcbed41f 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -13,7 +13,7 @@ use kurbo::Shape; use skrifa::MetadataProvider; use skrifa::charmap::Charmap; use skrifa::instance::Size; -use vectorcraft_doc::text::{INLINE_CHAR, MAX_AKI}; +use vectorcraft_doc::text::{INLINE_CHAR, MAX_AKI, MAX_TSUME}; use vectorcraft_doc::{CharStyle, Digits, InlineArt}; use crate::features::{OtFeatures, ligatures_suppressed_by}; @@ -969,9 +969,9 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: /// 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`]) and Insert Aki ([`aki`]) come -/// from the cluster's first character, so they do not split a ligature or a letter from its marks -/// either. Warichu Settings count only on characters marked Warichu ([`warichu_alike`]). +/// The space before a line's first character ([`space_before`]), Insert Aki ([`aki`]) and Tsume +/// ([`tighten`]) come from the cluster's first character, so they do not split a ligature or a +/// letter from its marks either. Warichu Settings count only on characters marked Warichu ([`warichu_alike`]). fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { // Exhaustive, like `shapes_alike`: compare borrowed fields without copying feature lists. let CharStyle { @@ -986,6 +986,7 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { kerning_before, aki_left, aki_right, + tsume, optical_kerning, baseline_shift, h_scale, @@ -1034,6 +1035,7 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { kerning_before == &a.kerning_before && aki_left == &a.aki_left && aki_right == &a.aki_right + && tsume == &a.tsume && offsets == other_offsets && font_family == &b.font_family && font_style == &b.font_style @@ -1094,7 +1096,7 @@ pub(crate) fn warichu_alike(a: &CharStyle, b: &CharStyle) -> bool { /// 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`]), -/// Insert Aki ([`aki`]), the OpenType features (each run's apply to its own characters, as features +/// Insert Aki ([`aki`]), Tsume ([`tighten`]), 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 { @@ -1111,6 +1113,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { kerning_before: _, aki_left: _, aki_right: _, + tsume: _, optical_kerning: _, baseline_shift: _, h_scale, @@ -1332,6 +1335,9 @@ struct Shaped { /// from their letters and the character-level pass matches ([`crate::harakat`]); empty /// otherwise (glyphs keep their shaping-cluster start). chars: Vec, + /// Each glyph's x advance without the font's pair kerning, the width Tsume measures, when + /// horizontal type with Tsume sets the same glyphs without it; empty otherwise. + unkerned: Vec, } /// The features that turn the whole-text features `base` into `own` over the clusters `range`, put @@ -1443,6 +1449,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe } ranged }; + // Horizontal type with Tsume on any of the segment's characters. + let tsume_here = !feats.vertical && runs.get(source_runs.clone()).unwrap_or_default().iter().any(|(_, s)| s.tsume > 0.0); // 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. @@ -1452,7 +1460,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe .filter(|l| l.len() <= 35 && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) .and_then(|l| l.parse::().ok()); let mut shaped = Shaped::default(); - let Shaped { raw, proportional, vpal, chars } = &mut shaped; + let Shaped { raw, proportional, vpal, chars, unkerned } = &mut shaped; let hb_shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); // The baseline and character-level pass use exactly the same resolved features. @@ -1531,6 +1539,16 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe *proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect(); } } + if tsume_here { + // Tsume measures each glyph on its own width: the same glyphs set without the + // font's pair kerning (`kern` and `apkn`) give it. + let mut bare = along_features.clone(); + bare.extend([b"kern", b"apkn"].map(|t| Feature::new(Tag::new(t), 0, range.start..=range.end))); + let bare = shape(&bare, along); + if same_glyphs(&bare) { + *unkerned = bare.iter().map(|g| g.2).collect(); + } + } if feats.proportional_vertical(st) { // `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set // top to bottom with it (and the vertical kerning that goes with it, `vkrn` and `vapk`) @@ -1577,7 +1595,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe shaped }; let first_optical = if optical(range.start) { Some(range.start) } else { boundaries().find(|&b| optical(b)) }; - let Shaped { raw, proportional, vpal, chars } = if let Some(first) = first_optical.filter(|_| !feats.vertical) { + let Shaped { raw, proportional, vpal, chars, unkerned } = if let Some(first) = first_optical.filter(|_| !feats.vertical) { // The pair entering Optical keeps the left character's font kerning. Subsequent // Optical pairs use outline spacing, so font pair features stop at its second character. let optical_off = if first == range.start { @@ -1711,11 +1729,16 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl; let ch = first_char(cl); let mut adv = xa as f64 * k * hs; + // The glyph's own width, without the font's pair kerning and before the tracking, manual + // kerning and Insert Aki after it: what Tsume measures. + let mut own = unkerned.get(gi).map_or(adv, |&u| f64::from(u) * k * hs); // The chosen glyph takes its own advance. let chosen = chosen_at(gi); let gid = match chosen { Some(c) => { - adv += (face.advance(c.gid) - face.advance(gid)) * k * hs; + let wider = (face.advance(c.gid) - face.advance(gid)) * k * hs; + adv += wider; + own += wider; c.gid } None => gid, @@ -1723,7 +1746,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let mut kern_shift = 0.0; let mut tracked = 0.0; let (mut aki_shift, mut aki_in_adv) = (0.0, 0.0); - if ch == SOFT_HYPHEN || ch == '\u{2028}' { + let invisible = ch == SOFT_HYPHEN || ch == '\u{2028}'; + if invisible { adv = 0.0; } else { let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0); @@ -1785,9 +1809,61 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe cursive, hyphen_language: crate::hyphen::Language::from_tag(gst.language.as_deref()), }); + // Tsume takes its share of the blank space beside the cluster's ink once the cluster is + // complete, in horizontal type. + if last_in_cluster && !invisible && !feats.vertical && gst.tsume > 0.0 { + let first = out.iter().rposition(|g| g.byte != cl).map_or(0, |i| i + 1); + tighten(out.get_mut(first..).unwrap_or_default(), own, gst.tsume); + } } } +/// Tsume ([`CharStyle::tsume`]) on one cluster: `glyphs` are its glyphs in the order they are +/// drawn along the line, the last one with `own` as its width before the tracking, kerning and +/// Insert Aki its advance holds. `tsume` percent of the blank space between the cluster's own width +/// and its ink, on each side, comes out of the cluster's advance, and its glyphs are drawn the +/// left side's share earlier. A cluster without ink, such as a space, or with a width that is not +/// positive, keeps its place and width. +fn tighten(glyphs: &mut [SGlyph], own: f64, tsume: f64) { + let Some(last) = glyphs.len().checked_sub(1) else { return }; + let share = if tsume.is_nan() { 0.0 } else { tsume.clamp(0.0, MAX_TSUME) / 100.0 }; + let (mut pen, mut ink): (f64, Option<(f64, f64)>) = (0.0, None); + for (i, g) in glyphs.iter().enumerate() { + if let Some((a, b)) = ink_across(&g.face, g.gid) { + let (a, b) = (pen + g.dx + a * g.sx, pen + g.dx + b * g.sx); + let (lo, hi) = (a.min(b), a.max(b)); + ink = Some(ink.map_or((lo, hi), |(l, h)| (l.min(lo), h.max(hi)))); + } + pen += if i == last { own } else { g.adv }; + } + let Some((lo, hi)) = ink.filter(|(lo, hi)| lo.is_finite() && hi.is_finite()) else { return }; + if !(pen.is_finite() && pen > 0.0) { + return; + } + let left = lo.clamp(0.0, pen) * share; + let right = (pen - hi).clamp(0.0, pen) * share; + for g in glyphs.iter_mut() { + g.dx -= left; + } + if let Some(g) = glyphs.get_mut(last) { + g.adv -= left + right; + } +} + +/// The left and right edges of glyph `gid`'s outline in `face`, in font units; `None` for a glyph +/// without an outline (a space, or a bitmap or color glyph without one). +fn ink_across(face: &FontFace, gid: u32) -> Option<(f64, f64)> { + let font = face.skrifa()?; + let glyph = font.outline_glyphs().get(skrifa::GlyphId::new(gid))?; + let mut pen = crate::fontdb::FlipPen(kurbo::BezPath::new()); + glyph.draw(skrifa::outline::DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen).ok()?; + if pen.0.elements().is_empty() { + return None; + } + let b = pen.0.bounding_box(); + Some((b.x0, b.x1)) +} + #[cfg(test)] mod optical_tests { use super::*; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e7e4f33ff6..423984a630 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -30,6 +30,7 @@ mod shape_plans; mod snap; mod svg_glyphs; mod sysfonts; +mod tsume; mod typo; mod variable; mod versions; diff --git a/crates/text/src/tests/combos.rs b/crates/text/src/tests/combos.rs index 0146871265..4f57aa6972 100644 --- a/crates/text/src/tests/combos.rs +++ b/crates/text/src/tests/combos.rs @@ -465,18 +465,72 @@ fn composer_x_mixed_sizes_in_a_frame_that_widens() { #[test] fn composer_x_mixed_sizes_overflow_a_shaped_frame() { - // A stepped frame too short for the paragraph: the lines that fit are set, in order, each once - // and within its room, and the frame overflows. + // A stepped frame too short for the paragraph: the every-line composer still breaks the lines + // that fit as one paragraph, with the lines past the frame's end measured as wide as the last + // line the frame holds. Here that is the narrow part, so the lines that fit break as in a frame + // tall enough for the whole paragraph, and not as the single-line composer breaks them. They + // are set in order, each once and within its room, and the frame overflows. let w = x_width(); - let mut t = stepped(w, w * 0.5, 40.0, 75.0); - t.runs = with_x(sans(36.0)); - t.para.composer = Composer::EveryLine; - let l = layout(db(), &t); + let set = |bottom: f64, composer: Composer| { + let mut t = stepped(w, w * 0.5, 40.0, bottom); + t.runs = with_x(sans(36.0)); + t.para.composer = composer; + let l = layout(db(), &t); + (line_texts(&t, &l), l, t) + }; + let (every, l, t) = set(75.0, Composer::EveryLine); assert!(l.overflow && l.lines.len() >= 2, "{:?}", l.lines); assert_eq!(l.lines[0].start, 0); assert!(l.lines.windows(2).all(|p| p[0].end == p[1].start && p[0].start < p[0].end), "{:?}", l.lines); assert!(past_their_room(&t, &l).is_empty(), "{:?}", l.lines); assert!(l.lines.iter().all(|li| li.baseline + li.descent <= 75.0 + 0.01), "{:?}", l.lines); + assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]); + let (whole, ..) = set(300.0, Composer::EveryLine); + assert_eq!(every, whole[..every.len()]); + let (greedy, ..) = set(75.0, Composer::SingleLine); + assert_ne!(every, greedy); + // A frame with no room for a line sets none. + for composer in [Composer::EveryLine, Composer::SingleLine] { + let mut t = stepped(w, w * 0.5, 4.0, 8.0); + t.runs = with_x(sans(36.0)); + t.para.composer = composer; + let l = layout(db(), &t); + assert!(l.lines.is_empty() && l.overflow, "{composer:?}: {:?}", l.lines); + } +} + +#[test] +fn composer_x_mixed_sizes_overflow_around_text_wrap() { + // A rectangle that wraps around an object beside its lower lines, too short for the + // paragraph: the every-line composer breaks the lines that fit as one paragraph, each span + // measured across its line's whole height, and the frame overflows. No glyph runs into the + // object. + let w = x_width(); + let object = Rect::new(w * 0.4, 40.0, w * 0.6, 70.0); + let set = |composer: Composer| { + let mut runs = with_x(sans(36.0)); + runs[0] = run("Destroy all creatures with toughness\u{a0}", sans(12.0)); + let mut t = area(runs, Rect::new(0.0, 0.0, w, 80.0)); + t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&object.to_path(0.1)), wrap: Default::default() }]; + t.para.composer = composer; + let l = layout(db(), &t); + (line_texts(&t, &l), l, t) + }; + let (every, l, t) = set(Composer::EveryLine); + assert!(l.overflow && l.lines.len() >= 3, "{:?}", l.lines); + assert!(l.lines.windows(2).all(|p| p[0].end == p[1].start && p[0].start < p[0].end), "{:?}", l.lines); + assert!(past_their_room(&t, &l).is_empty(), "{:?}", l.lines); + let text = t.plain_text(); + let hits = l.glyphs.iter().filter(|g| { + let li = &l.lines[g.line]; + let across = li.baseline - li.ascent < object.y1 && li.baseline + li.descent > object.y0; + across && !text[g.byte..].starts_with(char::is_whitespace) && g.origin.x < object.x1 && g.origin.x + g.advance > object.x0 + }); + assert_eq!(hits.count(), 0, "{:?}", l.lines); + // The lines that fit break as in `composer_x_mixed_sizes_around_text_wrap`'s taller frame. + assert_eq!(every, ["Destroy all creatures with", "toughness\u{a0}X", "or less. Then", "draw a card for", "each creature"]); + let (greedy, ..) = set(Composer::SingleLine); + assert_ne!(every, greedy); } #[test] diff --git a/crates/text/src/tests/tsume.rs b/crates/text/src/tests/tsume.rs new file mode 100644 index 0000000000..72f0d69f19 --- /dev/null +++ b/crates/text/src/tests/tsume.rs @@ -0,0 +1,143 @@ +//! Tsume (`CharStyle::tsume`): the share of the blank space on each side of a character's ink, +//! inside its own width, that horizontal type takes away, the glyph keeping its shape. +use super::{area, db, point, style}; +use crate::*; +use kurbo::Shape; +use vectorcraft_doc::{CharStyle, Justify}; + +/// Where glyph `byte` is drawn along a horizontal line. +fn drawn_x(l: &TextLayout, byte: usize) -> f64 { + l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf.as_coeffs()[4]).unwrap() +} + +/// The left and right edges of glyph `byte`'s ink. +fn ink(l: &TextLayout, byte: usize) -> (f64, f64) { + let b = l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.outline.bounding_box()).unwrap(); + (b.x0, b.x1) +} + +/// `t` with `f` applied to the style of the character at `byte`. +fn with(t: &TextObject, byte: usize, f: impl Fn(&mut CharStyle)) -> TextObject { + let mut t = t.clone(); + let end = edit::next_char(&t.plain_text(), byte); + edit::style_range(&mut t.runs, byte, end, f); + t +} + +/// How far each of `bytes` moved along the line from `a` to `b`. +fn moves(a: &TextObject, b: &TextObject, bytes: &[usize]) -> Vec { + let (la, lb) = (layout(db(), a), layout(db(), b)); + bytes.iter().map(|&i| drawn_x(&lb, i) - drawn_x(&la, i)).collect() +} + +fn near(values: &[f64], expected: &[f64]) -> bool { + values.len() == expected.len() && values.iter().zip(expected).all(|(v, e)| (v - e).abs() < 1e-6) +} + +/// The blank space before and after the O of "HOH" in `t`'s layout: between the start of its +/// width and its ink, and between its ink and the next character. +fn o_bearings(t: &TextObject) -> (f64, f64) { + let l = layout(db(), t); + let (x0, x1) = ink(&l, 1); + (x0 - drawn_x(&l, 1), drawn_x(&l, 2) - x1) +} + +#[test] +fn tsume_takes_its_share_of_the_space_beside_the_ink() { + let plain = point("HOH", style(100.0)); + let (before, after) = o_bearings(&plain); + assert!(before > 1.0 && after > 1.0, "{before} {after}"); + let plain_layout = layout(db(), &plain); + let width = ink(&plain_layout, 1).1 - ink(&plain_layout, 1).0; + for tsume in [25.0, 50.0, 100.0] { + let tight = with(&plain, 1, |s| s.tsume = tsume); + let share = tsume / 100.0; + // The O's ink moves back by its share of the space before it; the H after it also moves + // back by the share of the space after the O. + let l = layout(db(), &tight); + let (x0, x1) = ink(&l, 1); + let moved = (x0 - ink(&plain_layout, 1).0, ink(&l, 2).0 - ink(&plain_layout, 2).0); + assert!((moved.0 + before * share).abs() < 1e-6, "{tsume}: {moved:?}"); + assert!((moved.1 + (before + after) * share).abs() < 1e-6, "{tsume}: {moved:?}"); + // The glyph keeps its shape, and the H before it stays. + assert!((x1 - x0 - width).abs() < 1e-6, "{tsume}: {} {width}", x1 - x0); + assert!((ink(&l, 0).0 - ink(&plain_layout, 0).0).abs() < 1e-9); + } + // At 100% the O's ink starts where the H before it ends its width, and the next H starts its + // width where the O's ink ends. + let l = layout(db(), &with(&plain, 1, |s| s.tsume = 100.0)); + let cell_start = drawn_x(&plain_layout, 1); + assert!((ink(&l, 1).0 - cell_start).abs() < 1e-6 && (drawn_x(&l, 2) - ink(&l, 1).1).abs() < 1e-6, "{:?} {cell_start}", ink(&l, 1)); +} + +#[test] +fn zero_out_of_range_and_spaces_keep_the_layout() { + let plain = point("H O", style(100.0)); + let base = layout(db(), &plain); + for (tsume, at) in [(0.0, 2), (f64::NAN, 2), (-50.0, 2), (100.0, 1)] { + // 0, a value that is not a number, a negative value, and a space at 100%. + let l = layout(db(), &with(&plain, at, |s| s.tsume = tsume)); + let xs: Vec = l.glyphs.iter().map(|g| g.xf.as_coeffs()[4]).collect(); + let base_xs: Vec = base.glyphs.iter().map(|g| g.xf.as_coeffs()[4]).collect(); + assert_eq!(xs, base_xs, "{tsume} at {at}"); + } + // A value above 100 takes all the space, as 100 does. + let most = moves(&plain, &with(&plain, 2, |s| s.tsume = 500.0), &[2]); + let full = moves(&plain, &with(&plain, 2, |s| s.tsume = 100.0), &[2]); + assert!(near(&most, &full) && full[0] < -1.0, "{most:?} {full:?}"); +} + +#[test] +fn tracking_and_insert_aki_are_added_after_the_tsume() { + let plain = point("HOH", style(100.0)); + let tight = moves(&plain, &with(&plain, 1, |s| s.tsume = 50.0), &[0, 1, 2]); + let spaced = moves(&plain, &with(&plain, 1, |s| (s.tsume, s.aki_left, s.tracking) = (50.0, 250.0, 100.0)), &[0, 1, 2]); + // 25 pt of Aki before the O, and 10 pt of tracking after it, on top of the Tsume. + assert!(tight[1] < -1.0 && tight[2] < tight[1], "{tight:?}"); + assert!(near(&spaced, &[0.0, tight[1] + 25.0, tight[2] + 35.0]), "{tight:?} {spaced:?}"); +} + +#[test] +fn the_font_kerning_is_added_after_the_tsume() { + // An L, with blank space after its ink, before a letter the font kerns it with: its Tsume is + // the same as before an H, which the font does not kern it with. + let lh = point("LH", style(100.0)); + let kerned = ["LT", "LV", "LY", "LW"] + .map(|s| point(s, style(100.0))) + .into_iter() + .find(|t| drawn_x(&layout(db(), t), 1) < drawn_x(&layout(db(), &lh), 1) - 1.0); + let Some(kerned) = kerned else { panic!("the test font kerns none of the pairs") }; + let before_kerned = moves(&kerned, &with(&kerned, 0, |s| s.tsume = 100.0), &[1]); + let before_h = moves(&lh, &with(&lh, 0, |s| s.tsume = 100.0), &[1]); + assert!(near(&before_kerned, &before_h) && before_h[0] < -10.0, "{before_kerned:?} {before_h:?}"); +} + +#[test] +fn a_letter_with_its_mark_takes_the_tsume_of_the_letter() { + let plain = point("H\u{301}O", style(100.0)); + let after = "H\u{301}".len(); + let letter = moves(&plain, &with(&plain, 0, |s| s.tsume = 100.0), &[after]); + let mark = moves(&plain, &with(&plain, 1, |s| s.tsume = 100.0), &[after]); + assert!(letter[0] < -1.0 && near(&mark, &[0.0]), "{letter:?} {mark:?}"); +} + +#[test] +fn justification_keeps_the_tsume() { + let plain = area("HOH HOH", style(20.0), Rect::new(0.0, 0.0, 400.0, 400.0), Justify::JustifyAll); + let tight = with(&plain, 1, |s| s.tsume = 100.0); + let (lp, lt) = (layout(db(), &plain), layout(db(), &tight)); + // The O stays nearer the H before it by the space Tsume took before its ink. + let gap = |l: &TextLayout| ink(l, 1).0 - ink(l, 0).1; + assert!(gap(<) < gap(&lp) - 1.0, "{} {}", gap(&lp), gap(<)); +} + +#[test] +fn vertical_type_keeps_its_spacing() { + for text in ["§§", "ab"] { + let mut plain = point(text, style(20.0)); + plain.xf = Affine::IDENTITY; + plain.vertical = true; + let centers = |t: &TextObject| layout(db(), t).glyphs.iter().map(|g| g.outline.bounding_box().center()).collect::>(); + assert_eq!(centers(&with(&plain, 0, |s| s.tsume = 100.0)), centers(&plain), "{text}"); + } +} diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index 0ac64f5d27..adbf3b2c70 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -397,6 +397,80 @@ fn a_continued_paragraph_fills_the_frame_it_continues_into() { assert_eq!(checked, 16); } +/// Area type in a frame `w` wide down to `y` and `narrow` wide below it, down to `bottom`. +fn stepped_frame(w: f64, narrow: f64, y: f64, bottom: f64) -> TextObject { + let mut t = frame(0.0); + let path = kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((0.0, 0.0).into()), + kurbo::PathEl::LineTo((w, 0.0).into()), + kurbo::PathEl::LineTo((w, y).into()), + kurbo::PathEl::LineTo((narrow, y).into()), + kurbo::PathEl::LineTo((narrow, bottom).into()), + kurbo::PathEl::LineTo((0.0, bottom).into()), + kurbo::PathEl::ClosePath, + ]); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&path) }; + t +} + +/// A paragraph of mixed type sizes that a stepped frame cannot hold goes on in the next frame. The +/// every-line composer breaks the lines the first frame holds as one paragraph, as in a frame tall +/// enough for all of it and not as the single-line composer breaks them, and the first frame +/// takes the text of those lines. The next frame sets the rest at its own width, without the +/// first-line indent, and no character is lost or repeated. The frames take the same shares as +/// when each is measured with the whole rest of the story. +#[test] +fn a_mixed_size_paragraph_continued_from_a_shaped_frame_is_composed_in_both() { + use vectorcraft_doc::Composer; + let db = FontDb::global(); + let small = CharStyle { size: 12.0, ..CharStyle::default() }; + let big = CharStyle { size: 36.0, ..CharStyle::default() }; + let probe = layout(db, &TextObject::point(vectorcraft_geom::Point::ZERO, "Destroy all creatures with toughness X or", small.clone())); + let w = probe.lines[0].x1 - probe.lines[0].x0 + 2.0; + let story = vec![ + TextRun { text: "Destroy all creatures with toughness ".into(), style: small.clone(), inline: None }, + TextRun { text: "X".into(), style: big, inline: None }, + TextRun { text: " or less. Then draw a card for each creature destroyed this way.\nSecond paragraph.".into(), style: small, inline: None }, + ]; + let text: String = story.iter().map(|r| r.text.as_str()).collect(); + let (a, tall) = (stepped_frame(w, w * 0.5, 40.0, 85.0), stepped_frame(w, w * 0.5, 40.0, 300.0)); + let b = sized(200.0, w * 0.75, 300.0); + let texts = |lay: &crate::TextLayout, shown: &str| lay.lines.iter().map(|l| shown[l.start..l.end].trim_end().to_string()).collect::>(); + let shares = |composer: Composer, indent: f64| { + let para = ParaStyle { composer, first_line_indent: indent, ..Default::default() }; + let paras = [para.clone(), para]; + let parts = distribute(db, &[&a, &b], &story, ¶s); + assert_eq!(parts.iter().map(|p| p.continues).collect::>(), [false, true], "{composer:?}"); + let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "{composer:?}: no character lost or repeated"); + same_as_whole_rest(db, &[&a, &b], &story, ¶s, &format!("{composer:?}, indent {indent}")); + let shown = |p: &Slice| p.runs.iter().map(|r| r.text.as_str()).collect::(); + let lays: Vec<_> = [&a, &b].iter().zip(&parts).map(|(f, p)| laid(db, f, p)).collect(); + assert!(lays.iter().all(|l| !l.overflow), "{composer:?}: each frame holds its share"); + let whole = + laid(db, &tall, &Slice { runs: story.clone(), paras: paras.to_vec(), list_count: 0, list_level_counts: vec![], continues: false }); + let first = texts(&lays[0], &shown(&parts[0])); + // The first frame takes the text of the lines it sets. + assert_eq!(shown(&parts[0]).split_whitespace().collect::>(), first.join(" ").split_whitespace().collect::>()); + (first, texts(&whole, &text), lays, parts) + }; + for indent in [0.0, 20.0] { + let (every, whole, lays, parts) = shares(Composer::EveryLine, indent); + assert!(every.len() >= 2, "{every:?}"); + assert_eq!(every, whole[..every.len()], "indent {indent}"); + if indent == 0.0 { + assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]); + let (greedy, ..) = shares(Composer::SingleLine, indent); + assert_eq!(greedy, ["Destroy all creatures with toughness X", "or less. Then draw a", "card for each", "creature destroyed"]); + } + // The next frame starts at its left edge, and its lines keep within it. + assert!((line_start(&lays[1], 0) - 200.0).abs() < 1e-6, "{}", line_start(&lays[1], 0)); + assert!(lays[1].lines.iter().all(|l| l.x1 <= 200.0 + w * 0.75 + 1e-6), "{:?}", lays[1].lines); + assert!((line_start(&lays[0], 0) - indent).abs() < 1e-6, "{}", line_start(&lays[0], 0)); + assert!(parts[1].continues && lays[1].lines.len() >= 2, "{:?}", lays[1].lines); + } +} + /// `frames` holding the runs of `parts`, for [`continued`]. fn filled(frames: &[&TextObject], parts: &[Slice]) -> Vec { frames diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 309aa77409..bb066c0e8a 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -19,7 +19,7 @@ mod lights; pub use lights::{Light, Lights, MAX_LIGHTS}; mod caps; mod mesh; -pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; +pub use mesh::{Mesh, MeshedSolid, extrude_mesh, extrude_meshed, inflate_meshed, inflate_meshes, plane_mesh, revolve_mesh}; mod shadow; pub use shadow::{MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, TOO_COMPLEX_SHADOW}; @@ -371,6 +371,10 @@ pub struct Face { /// For a face of the swept side (not an end cap): where its corners lie on the side surface, /// for a graphic mapped onto it. pub surface: Option, + /// The face's plane in view space and the projection it was drawn with, for its depth at a + /// point of the drawing ([`DepthPlane::depth_at`]). `None` when its corners lie more than + /// 0.001 pt off one plane or a corner is at or past the eye. + pub plane: Option, } /// Where a face of Revolve's swept side lies on the side surface, unrolled flat: x runs around the @@ -638,6 +642,14 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi normal = V3(-normal.0, -normal.1, -normal.2); } let flat_brightness = brightness(&o, normal, light); + // As for an extruded face: no plane when a corner lies off it, or when the projection holds a + // corner just in front of the eye, so the drawing no longer matches the plane's projection. + let centre = vertices.iter().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2)); + let centre = V3(centre.0 / vertices.len() as f64, centre.1 / vertices.len() as f64, centre.2 / vertices.len() as f64); + let offset = normal.dot(centre); + let held = eye.is_some_and(|e| vertices.iter().any(|v| e - v.2 < e * 1e-3)); + let flat = !held && vertices.iter().all(|v| (normal.dot(*v) - offset).abs() <= 1e-3); + let plane = flat.then_some(DepthPlane { normal: [normal.0, normal.1, normal.2], offset, center: origin, eye }); let points: Vec<_> = vertices .iter() .map(|v| { @@ -707,6 +719,7 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi cap: false, visibility_pieces: None, surface: None, + plane, })) } diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index b12bd315f5..cd9623191c 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -10,8 +10,8 @@ use vectorcraft_geom::{FillRule, PathData, Rect}; -use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces}; -use crate::{Extrude, Inflate, Revolve, V3, View}; +use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces}; +use crate::{Extrude, Facet, Inflate, Revolve, V3, View}; /// Most steps the triangulation of one solid may take: a face's ears and bridges each test the /// face's other corners. @@ -56,12 +56,12 @@ impl Mesh { /// Extrude & Bevel's solid ([`crate::extrude`]) as a mesh. pub fn extrude_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result { - extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces, _)| mesh(&faces)) + extrude_faces(path, rule, bounds, options, view, Keep::Faces).and_then(|(_, faces, _)| mesh(&faces?)) } /// Rotate's plane ([`crate::plane`]) as a mesh. pub fn plane_mesh(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result { - plane_faces(path, rule, bounds, view, true).and_then(|(_, faces, _)| mesh(&faces)) + plane_faces(path, rule, bounds, view, Keep::Faces).and_then(|(_, faces, _)| mesh(&faces?)) } /// Inflate's solids for several shapes of one object ([`crate::inflate_together`]) as meshes, one @@ -72,7 +72,49 @@ pub fn inflate_meshes( options: Inflate, view: View, ) -> impl Iterator> + use<> { - inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces, _)| mesh(&faces))) + inflate_faces(shapes, bounds, options, view, Keep::Faces).map(|r| r.and_then(|(_, faces, _)| mesh(&faces?))) +} + +/// One solid built once for both its drawing and its mesh: the facets [`crate::extrude`] or +/// [`crate::inflate_each`] gives, and the faces before projection that [`MeshedSolid::mesh`] cuts +/// into the mesh [`extrude_mesh`] or [`inflate_meshes`] gives. +#[derive(Clone, Debug)] +pub struct MeshedSolid { + pub facets: Vec, + /// The faces before projection, or the error the mesh alone gives when there are too many. + faces: Result, String>, +} + +/// A solid with no facets and an empty mesh. +impl Default for MeshedSolid { + fn default() -> Self { + Self { facets: vec![], faces: Ok(vec![]) } + } +} + +impl MeshedSolid { + /// The solid as a mesh. The faces are cut into triangles on each call. + pub fn mesh(&self) -> Result { + mesh(self.faces.as_ref().map_err(String::clone)?) + } +} + +/// Extrude & Bevel's solid ([`crate::extrude`]) and its mesh ([`extrude_mesh`]) from one build. An +/// error where [`crate::extrude`] gives one. +pub fn extrude_meshed(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result { + extrude_faces(path, rule, bounds, options, view, Keep::Both).map(|(facets, faces, _)| MeshedSolid { facets, faces }) +} + +/// Inflate's solids for several shapes of one object ([`crate::inflate_each`]) and their meshes +/// ([`inflate_meshes`]), each solid built once, as the iterator reaches it. An error where +/// [`crate::inflate_each`] gives one. +pub fn inflate_meshed( + shapes: &[(&PathData, FillRule)], + bounds: Rect, + options: Inflate, + view: View, +) -> impl Iterator> + use<> { + inflate_faces(shapes, bounds, options, view, Keep::Both).map(|r| r.map(|(facets, faces, _)| MeshedSolid { facets, faces })) } /// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 2011d6689c..76f9b04ecc 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use vectorcraft_geom::{BezPath, FillRule, PathData, Rect}; use super::*; -use crate::{Bevel, Edge}; +use crate::{Bevel, BevelShape, Edge}; fn square(b: &mut BezPath, x: f64, y: f64, s: f64, clockwise: bool) { b.move_to((x, y)); @@ -372,3 +372,130 @@ fn join_numbers_each_parts_corners_after_the_last() { assert!(both.triangles.iter().all(|t| t.iter().all(|i| (*i as usize) < both.positions.len()))); assert_eq!(both.triangles[one.triangles.len()][0], one.triangles[0][0] + one.positions.len() as u32); } + +/// A mesh's positions and normals as bits, and its triangles. +type MeshBits = (Vec<[u64; 3]>, Vec<[u64; 3]>, Vec<[u32; 3]>); + +/// The bits of every number in `m`, for comparing two meshes exactly. +fn bits(m: &Mesh) -> MeshBits { + (m.positions.iter().map(|p| p.map(f64::to_bits)).collect(), m.normals.iter().map(|n| n.map(f64::to_bits)).collect(), m.triangles.clone()) +} + +/// A star of `points` points about (100, 100), with a square hole: curved and straight edges both. +fn star(points: usize) -> (PathData, Rect) { + let mut b = BezPath::new(); + for k in 0..2 * points { + let (s, c) = (k as f64 * std::f64::consts::PI / points as f64).sin_cos(); + let r = if k % 2 == 0 { 90.0 } else { 45.0 }; + let p = (100.0 + r * c, 100.0 + r * s); + if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 3.0, p.1), (p.0, p.1 + 3.0), p) } + } + b.close_path(); + square(&mut b, 90.0, 90.0, 20.0, true); + shape(&b) +} + +/// The views the solids are compared in: straight on, turned, with perspective, and with colored +/// lights (Inflate's smooth triangles). +fn views() -> Vec { + let turned = View { rotation_x: -25.0, rotation_y: 35.0, rotation_z: 10.0, ..View::default() }; + vec![front(), turned, View { perspective: 60.0, ..turned }, View { light_color: [1.0, 0.5, 0.25], ..turned }] +} + +/// Extrude & Bevel built once for its facets and its mesh gives what [`crate::extrude`] and +/// [`extrude_mesh`] give apart, to the bit, errors included. +#[test] +fn an_extruded_solid_and_its_mesh_match_the_two_built_apart() { + let bevel = Bevel { shape: BevelShape::Rounded, width: 6.0, height: 5.0, ..Bevel::default() }; + let options = [ + Extrude::default(), + Extrude { depth: 0.0, ..Extrude::default() }, + Extrude { cap: false, ..Extrude::default() }, + Extrude { hidden: true, ..Extrude::default() }, + Extrude { twist: 90.0, taper: 0.6, ..Extrude::default() }, + Extrude { bevel: Some(bevel), ..Extrude::default() }, + Extrude { bevel: Some(Bevel { both_sides: true, inside: true, ..bevel }), twist: 45.0, ..Extrude::default() }, + ]; + let (p, r) = star(7); + for view in views() { + for o in options { + let meshed = extrude_meshed(&p, FillRule::NonZero, r, o, view).unwrap(); + let facets = crate::extrude(&p, FillRule::NonZero, r, o, view).unwrap(); + assert_eq!(format!("{:?}", meshed.facets), format!("{facets:?}"), "{o:?} {view:?}"); + let apart = extrude_mesh(&p, FillRule::NonZero, r, o, view).unwrap(); + assert!(!apart.is_empty()); + assert_eq!(bits(&meshed.mesh().unwrap()), bits(&apart), "{o:?} {view:?}"); + } + } + // A solid that can't be built gives the same error. + let flat = Rect::new(0.0, 0.0, f64::INFINITY, 10.0); + let o = Extrude::default(); + let error = crate::extrude(&p, FillRule::NonZero, flat, o, front()).err(); + assert!(error.is_some()); + assert_eq!(extrude_meshed(&p, FillRule::NonZero, flat, o, front()).err(), error); +} + +/// Inflate's solids built once for their facets and meshes give what [`crate::inflate_each`] and +/// [`inflate_meshes`] give apart, to the bit: several puffs under one eye, both sides, open +/// backs, and twisted sides cut finer than room is left for. +#[test] +fn inflated_solids_and_their_meshes_match_the_two_built_apart() { + let (star, _) = star(5); + let mut b = BezPath::new(); + square(&mut b, 200.0, 20.0, 60.0, false); + let (box_, _) = shape(&b); + let ring: Vec = (0..600) + .map(|k| { + let (s, c) = (k as f64 * std::f64::consts::TAU / 600.0).sin_cos(); + vectorcraft_geom::Point::new(100.0 + 90.0 * c, 100.0 + 90.0 * s) + }) + .collect(); + let ring = PathData::single(vectorcraft_geom::SubPath::polyline(&ring, true)); + let sets = [vec![(&star, FillRule::NonZero), (&box_, FillRule::NonZero), (&star, FillRule::EvenOdd)], vec![(&ring, FillRule::NonZero)]]; + let bounds = Rect::new(0.0, 0.0, 280.0, 200.0); + let options = [ + Inflate::default(), + Inflate { depth: 30.0, both_sides: true, ..Inflate::default() }, + Inflate { depth: 20.0, cap: false, volume: 80.0, ..Inflate::default() }, + Inflate { depth: 40.0, twist: 360.0, taper: 0.7, both_sides: true, ..Inflate::default() }, + // Thin and barely twisted: the star's sides want more pieces than room is left after its + // front and back, and the mesh, which keeps the faces turned away, has less room left. + Inflate { depth: 0.1, twist: 0.1, both_sides: true, ..Inflate::default() }, + ]; + for shapes in &sets { + for view in views() { + for o in options { + let meshed: Vec<_> = inflate_meshed(shapes, bounds, o, view).collect(); + let facets: Vec<_> = crate::inflate_each(shapes, bounds, o, view).collect(); + let apart: Vec<_> = inflate_meshes(shapes, bounds, o, view).collect(); + assert_eq!(meshed.len(), shapes.len()); + for ((m, f), a) in meshed.iter().zip(&facets).zip(&apart) { + let (m, f, a) = (m.as_ref().unwrap(), f.as_ref().unwrap(), a.as_ref().unwrap()); + assert_eq!(format!("{:?}", m.facets), format!("{f:?}"), "{o:?} {view:?}"); + assert!(!a.is_empty()); + assert_eq!(bits(&m.mesh().unwrap()), bits(a), "{o:?} {view:?}"); + } + } + } + } +} + +/// One build gives both the facets and the mesh: each solid is built once. +#[test] +fn a_meshed_solid_is_built_once() { + let (p, r) = star(5); + let extruded = || crate::solid::EXTRUDED.with(|n| n.get()); + let start = extruded(); + let solid = extrude_meshed(&p, FillRule::NonZero, r, Extrude::default(), front()).unwrap(); + assert!(!solid.facets.is_empty() && solid.mesh().is_ok_and(|m| !m.is_empty())); + assert_eq!(extruded(), start + 1); + let inflated = || crate::solid::INFLATED.with(|n| n.get()); + let start = inflated(); + let mut b = BezPath::new(); + square(&mut b, 200.0, 20.0, 60.0, false); + let (q, _) = shape(&b); + for solid in inflate_meshed(&[(&p, FillRule::NonZero), (&q, FillRule::NonZero)], Rect::new(0.0, 0.0, 280.0, 200.0), Inflate::default(), front()) { + assert!(solid.is_ok_and(|s| !s.facets.is_empty() && s.mesh().is_ok_and(|m| !m.is_empty()))); + } + assert_eq!(inflated(), start + 2); +} diff --git a/crates/three-d/src/shadow.rs b/crates/three-d/src/shadow.rs index d7cd01a869..66842abad2 100644 --- a/crates/three-d/src/shadow.rs +++ b/crates/three-d/src/shadow.rs @@ -11,7 +11,7 @@ use std::ops::Range; use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect}; -use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces}; +use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces}; use crate::{Extrude, Inflate, MAX_SOLID_FACES, Revolve, V3, View, finite, perspective_scale}; /// Where the shadow falls. @@ -160,14 +160,14 @@ impl ShadowCaster { /// Add Extrude & Bevel's solid ([`crate::extrude`]). pub fn extrude(&mut self, path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { - let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, true)?; - self.add(faces, projection) + let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, Keep::Faces)?; + self.add(faces?, projection) } /// Add Rotate's plane ([`crate::plane`]). pub fn plane(&mut self, path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { - let (_, faces, projection) = plane_faces(path, rule, bounds, view, true)?; - self.add(faces, projection) + let (_, faces, projection) = plane_faces(path, rule, bounds, view, Keep::Faces)?; + self.add(faces?, projection) } /// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each @@ -182,11 +182,11 @@ impl ShadowCaster { options: Inflate, view: View, ) -> Vec, String>> { - inflate_faces(shapes, bounds, options, view, true) + inflate_faces(shapes, bounds, options, view, Keep::Faces) .enumerate() .map(|(i, r)| { let (_, faces, projection) = r?; - if casts.get(i).copied().unwrap_or(true) { self.add(faces, projection) } else { Ok(self.faces.len()..self.faces.len()) } + if casts.get(i).copied().unwrap_or(true) { self.add(faces?, projection) } else { Ok(self.faces.len()..self.faces.len()) } }) .collect() } diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 49b0738d9e..55d270f97d 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -20,9 +20,11 @@ mod membrane; mod outline; mod rings; +#[cfg(test)] +pub(crate) use build::EXTRUDED; #[cfg(test)] use build::{Camera, cuts, edge_distance, newell, section}; -pub(crate) use build::{Face3, extrude_faces, plane_faces}; +pub(crate) use build::{Face3, Keep, extrude_faces, plane_faces}; pub use build::{extrude, plane}; pub use face_map::{FaceMap, extrude_face_map, plane_face_map}; #[cfg(test)] @@ -293,8 +295,8 @@ pub struct Facet { /// (its front). `None` for every other facet. pub end: Option, /// The face's plane in view space and the projection it was drawn with, for its depth at a - /// point of the drawing ([`Facet::depth_at`]). `None` for Revolve's faces and for a face whose - /// corners lie more than 0.001 pt off one plane (a piece of a twisted wall). + /// point of the drawing ([`Facet::depth_at`]). `None` for a face whose corners lie more than + /// 0.001 pt off one plane (a piece of a twisted wall) or reach the eye. pub plane: Option, /// The contours in view space, before projection, for tests that measure depths. #[cfg(test)] @@ -365,7 +367,7 @@ impl Facet { PathData::from_bezpath(&bp) } - /// Revolve's face `face` as a facet: its polygon, curved outline, depth, normal and lighting. + /// Revolve's face `face` as a facet: its polygon, curved outline, depth, plane, normal and lighting. /// A face that joins the profile's ends round the axis is a side; a cap is an end. pub(crate) fn of_face(face: crate::Face) -> Self { Facet { @@ -380,7 +382,7 @@ impl Facet { // A revolved cap is not one of the planar faces graphics map onto. end: None, away: false, - plane: None, + plane: face.plane, #[cfg(test)] view: vec![], } diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 6d21805e74..2aa218e558 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -64,9 +64,25 @@ impl Camera { /// and its outward normal. pub(crate) type Face3 = (Vec>, V3); -/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection, -/// and the projection it used (`None` when nothing was built). -pub(crate) type Built = (Vec, Vec, Option); +/// What a solid's builder makes: its facets, its faces before projection when a mesh is asked for +/// (an error when they pass [`MAX_SOLID_FACES`] while the facets are built too), and the +/// projection it used (`None` when nothing was built). +pub(crate) type Built = (Vec, Result, String>, Option); + +#[cfg(test)] +thread_local! { + /// How many Extrude & Bevel solids this thread has built, for the tests. + pub(crate) static EXTRUDED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// What a solid's builder keeps: its facets, its faces before projection for a mesh, or both +/// from one build. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Keep { + Facets, + Faces, + Both, +} /// Facets being collected for one solid. pub(super) struct Builder<'a> { @@ -80,6 +96,11 @@ pub(super) struct Builder<'a> { pub(super) facets: Vec, /// With `Some`, every face goes here as it is, none hidden or projected, for a mesh. pub(super) mesh: Option>, + /// The faces are projected into `facets`; without it only `mesh` is kept. + pub(super) draw: bool, + /// The faces kept for the mesh passed [`MAX_SOLID_FACES`] while the facets were built too: the + /// mesh is too complex, and `mesh` keeps no more faces. + pub(super) lost: bool, } /// Where a face goes in the order the facets are drawn, back to front. @@ -98,20 +119,46 @@ pub(super) enum Order { Last, } -impl Builder<'_> { +impl<'a> Builder<'a> { + /// A builder for one solid seen by `camera`, keeping what `keep` asks for. + pub(super) fn new(camera: &'a Camera, closed: bool, hidden: bool, keep: Keep) -> Self { + Self { camera, closed, hidden, facets: vec![], mesh: (keep != Keep::Facets).then(Vec::new), draw: keep != Keep::Faces, lost: false } + } + /// Add a face of view-space contours with outward normal `n` (view space), drawn in `order`. pub(super) fn face(&mut self, contours: Vec>, n: V3, order: Order) -> Result<(), String> { let count: usize = contours.iter().map(Vec::len).sum(); if count < 3 { return Ok(()); } - if let Some(mesh) = &mut self.mesh { - mesh.push((contours, n.unit())); - if mesh.len() > MAX_SOLID_FACES { + if self.draw { + self.facet(&contours, count, n, order)?; + } + let Some(mesh) = &mut self.mesh else { return Ok(()) }; + mesh.push((contours, n.unit())); + if mesh.len() > MAX_SOLID_FACES { + if !self.draw { return Err(TOO_COMPLEX_SURFACE.into()); } - return Ok(()); + self.lose_mesh(); } + Ok(()) + } + + /// Keep no more faces for the mesh, which is too complex. + pub(super) fn lose_mesh(&mut self) { + self.mesh = None; + self.lost = true; + } + + /// How many faces the mesh holds, while the facets are built too and the mesh is kept. + pub(super) fn faces_kept(&self) -> Option { + self.mesh.as_ref().filter(|_| self.draw).map(Vec::len) + } + + /// Project a face of `count` view-space corners with outward normal `n` into a facet, drawn in + /// `order`, unless it is hidden or has no area. + fn facet(&mut self, contours: &[Vec], count: usize, n: V3, order: Order) -> Result<(), String> { let centre = contours.iter().flatten().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2)); let centre = V3(centre.0 / count as f64, centre.1 / count as f64, centre.2 / count as f64); let n = n.unit(); @@ -138,7 +185,7 @@ impl Builder<'_> { let flat = !held && contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3); let plane = flat.then_some(DepthPlane { normal: [n.0, n.1, n.2], offset, center: self.camera.origin, eye: self.camera.eye }); #[cfg(test)] - let view = contours.clone(); + let view = contours.to_vec(); let contours: Vec> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect(); if contours.iter().flatten().any(|p| !p.x.is_finite() || !p.y.is_finite()) { return Err("The 3D projection is not finite".into()); @@ -262,7 +309,7 @@ impl Builder<'_> { /// Give the facet that [`Builder::face`] added, when it added one after `before` facets, the /// outline `outline` makes from its projected contours. pub(super) fn outline(&mut self, before: usize, outline: impl FnOnce(&[Vec]) -> Option) { - if self.mesh.is_some() || self.facets.len() <= before { + if !self.draw || self.facets.len() <= before { return; } if let Some(f) = self.facets.last_mut() { @@ -273,7 +320,7 @@ impl Builder<'_> { /// Mark the facet that [`Builder::face`] added, when it added one after `before` facets, as the /// planar end `end` of the solid. pub(super) fn end(&mut self, before: usize, end: PlanarFace) { - if self.mesh.is_some() || self.facets.len() <= before { + if !self.draw || self.facets.len() <= before { return; } if let Some(f) = self.facets.last_mut() { @@ -281,15 +328,19 @@ impl Builder<'_> { } } - /// How many faces the builder holds: facets, or faces kept for a mesh. + /// How many faces the builder holds: facets, or without them the faces kept for a mesh. pub(super) fn len(&self) -> usize { - self.mesh.as_ref().map_or(self.facets.len(), Vec::len) + match &self.mesh { + Some(mesh) if !self.draw => mesh.len(), + _ => self.facets.len(), + } } /// The facets back to front, and the faces kept for a mesh (none without [`Builder::mesh`]). pub(super) fn finish(mut self) -> Built { self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth)); - (self.facets, self.mesh.unwrap_or_default(), Some(self.camera.projection())) + let faces = if self.lost { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(self.mesh.unwrap_or_default()) }; + (self.facets, faces, Some(self.camera.projection())) } } @@ -536,16 +587,18 @@ fn steps(o: &Extrude) -> Vec { /// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels /// and caps. pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { - extrude_faces(path, rule, bounds, options, view, false).map(|(facets, ..)| facets) + extrude_faces(path, rule, bounds, options, view, Keep::Facets).map(|(facets, ..)| facets) } -/// [`extrude`], or with `mesh` the solid's faces before projection instead of its facets. -pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, mesh: bool) -> Result { +/// [`extrude`], keeping the solid's facets, its faces before projection, or both, as `keep` asks. +pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, keep: Keep) -> Result { + #[cfg(test)] + EXTRUDED.with(|n| n.set(n.get() + 1)); check_bounds(bounds)?; let o = options.sanitized(); // No depth: the flat shape, seen from either side. if o.depth <= 0.0 { - return plane_faces(path, rule, bounds, view, mesh); + return plane_faces(path, rule, bounds, view, keep); } let steps = steps(&o); // One face for each edge of the outline in each band between two steps, and the two caps: @@ -561,7 +614,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio return Err(TOO_COMPLEX_SURFACE.into()); } let camera = Camera::new(view, bounds, o.depth); - let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder::new(&camera, o.cap, o.hidden, keep); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = |ring: &[Point], s: &Step| -> Vec { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() }; @@ -647,15 +700,15 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio /// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the /// shape's own, so that every part of one object turns about the same point). pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { - plane_faces(path, rule, bounds, view, false).map(|(facets, ..)| facets) + plane_faces(path, rule, bounds, view, Keep::Facets).map(|(facets, ..)| facets) } -/// [`plane`], or with `mesh` its face before projection instead of its facet. -pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result { +/// [`plane`], keeping its facet, its face before projection, or both, as `keep` asks. +pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, keep: Keep) -> Result { check_bounds(bounds)?; let rings = solid_rings_traced(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS, CURVE_PIECES)?; let camera = Camera::new(view, bounds, 0.0); - let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder::new(&camera, false, false, keep); let contours: Vec> = rings.iter().map(|r| r.points.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index d3773a92f9..677e9cc8bc 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,4 +1,4 @@ -use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; +use super::build::{Builder, Built, Camera, Keep, Order, Turn, check_bounds, cuts, fit, newell, outward}; use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, crossings, filled, material}; use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings}; use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View}; @@ -73,26 +73,26 @@ pub fn inflate_each( options: Inflate, view: View, ) -> impl Iterator, String>> + use<> { - inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets)) + inflate_faces(shapes, bounds, options, view, Keep::Facets).map(|r| r.map(|(facets, ..)| facets)) } -/// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. -/// Every shape's membrane is solved at once (the eye depends on the highest puff); each solid is -/// built as the iterator reaches it. +/// [`inflate_together`], keeping each solid's facets, its faces before projection, or both, as +/// `keep` asks. Every shape's membrane is solved at once (the eye depends on the highest puff); +/// each solid is built as the iterator reaches it. pub(crate) fn inflate_faces( shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, - mesh: bool, + keep: Keep, ) -> impl Iterator> + use<> { let o = options.sanitized(); let puffs: Vec, String>> = shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); puffs.into_iter().map(move |p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), - Ok(None) => Ok((vec![], vec![], None)), + Ok(Some(p)) => inflated(p, bounds, o, view, reach, keep), + Ok(None) => Ok((vec![], Ok(vec![]), None)), Err(e) => Err(e), }) } @@ -106,9 +106,9 @@ thread_local! { } /// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the -/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. With `mesh`, -/// its faces before projection instead. -fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: bool) -> Result { +/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. Its faces +/// before projection instead, or both, as `keep` asks. +fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, keep: Keep) -> Result { #[cfg(test)] INFLATED.with(|n| n.set(n.get() + 1)); let Puff { rings, edges, cell, nx, ny, m, height } = p; @@ -149,7 +149,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo let smooth = view.shade && view.has_colored_lights(); let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach)); // Without its back cap the object is a shell open behind, its inside showing. - let mut b = Builder { camera: &camera, closed: o.cap, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder::new(&camera, o.cap, false, keep); let half = o.depth / 2.0; // The back: turned and scaled by the twist and taper, its puff too. let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0); @@ -375,15 +375,40 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo let turn = Turn::new(bounds, o.twist, o.taper, t); ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect() }; - let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); + let cut: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); + let mut pieces = cut.clone(); fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len())); let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); if b.len().saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES { return Err(TOO_COMPLEX_SURFACE.into()); } - for (ring, pieces) in rings.iter().zip(&pieces) { - for k in 0..slices { - b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; + let sides = |b: &mut Builder<'_>, pieces: &[Vec]| -> Result<(), String> { + for (ring, pieces) in rings.iter().zip(pieces) { + for k in 0..slices { + b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; + } + } + Ok(()) + }; + // A mesh kept with the facets has the sides a mesh alone has: limited, and with a twist + // cut, by the faces the mesh holds, which can be more than the facets. + let mut own_sides = None; + if let Some(kept) = b.faces_kept() { + let mut own = cut; + fit(&mut own, slices, MAX_FACES.saturating_sub(kept)); + if kept.saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES { + b.lose_mesh(); + } else if own != pieces { + own_sides = Some((own, b.mesh.take())); + } + } + sides(&mut b, &pieces)?; + if let Some((own, faces)) = own_sides { + let mut m = Builder::new(&camera, o.cap, false, Keep::Faces); + m.mesh = faces; + match sides(&mut m, &own) { + Ok(()) => b.mesh = m.mesh, + Err(_) => b.lose_mesh(), } } } diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index e89a29afea..335f434419 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -225,12 +225,12 @@ fn a_flat_facet_measures_its_depth_on_its_plane() { /// eye, has no plane: its drawing no longer matches the plane's projection there. #[test] fn a_face_reaching_past_the_eye_has_no_plane() { - use super::build::{Builder, Order}; + use super::build::{Builder, Keep, Order}; let (_, b) = shape(&square(0.0, 0.0, 100.0)); let camera = Camera::new(View { perspective: 50.0, ..front() }, b, 0.0); let eye = camera.center_and_eye().1.unwrap(); for (far, has_plane) in [(eye / 2.0, true), (2.0 * eye, false)] { - let mut builder = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: None }; + let mut builder = Builder::new(&camera, false, false, Keep::Facets); let corners = vec![V3(-10.0, -10.0, 0.0), V3(10.0, -10.0, 0.0), V3(0.0, 10.0, far)]; builder.face(vec![corners], V3(0.0, -far, 20.0), Order::Depth).unwrap(); assert_eq!(builder.facets.len(), 1, "{far}"); diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs index fe42769ebb..5dba29c480 100644 --- a/crates/three-d/src/tests.rs +++ b/crates/three-d/src/tests.rs @@ -375,3 +375,35 @@ fn malformed_and_degenerate_inputs_are_bounded() { assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err()); assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty()); } + +/// A revolved face carries its plane: the depth it gives at each corner of its drawing is the +/// corner's view-space depth, with and without perspective. A face whose corners lie off one plane, +/// or with a corner at or past the eye, has none. +#[test] +fn a_revolved_face_measures_its_depth_on_its_plane() { + let o = Revolve { rotation_x: -30.0, rotation_y: 25.0, ..Default::default() }.sanitized(); + let origin = Point::new(50.0, 80.0); + let light = V3(0.0, 0.0, 1.0); + let [a, b, c, d] = [V3(-20.0, -10.0, 5.0), V3(20.0, -12.0, -15.0), V3(25.0, 15.0, -20.0), V3(-18.0, 14.0, 4.0)]; + // The fourth corner moved onto the plane of the first three. + let n = b.sub(a).cross(c.sub(a)); + let d = V3(d.0, d.1, a.2 - (n.0 * (d.0 - a.0) + n.1 * (d.1 - a.1)) / n.2); + let flat = [a, b, c, d]; + for eye in [None, Some(400.0)] { + let face = project_face(&flat, None, o, origin, eye, light).unwrap().unwrap(); + let plane = face.plane.unwrap(); + for (p, v) in face.points.iter().zip(&flat) { + let depth = plane.depth_at(*p).unwrap(); + assert!((depth - v.2).abs() < 1e-6, "{eye:?}: {depth} against {}", v.2); + } + let twisted = [a, b, c, V3(d.0, d.1, d.2 + 1.0)]; + assert!(project_face(&twisted, None, o, origin, eye, light).unwrap().unwrap().plane.is_none(), "{eye:?}"); + } + let past = [a, b, V3(c.0, c.1, 500.0)]; + assert!(project_face(&past, None, o, origin, Some(400.0), light).unwrap().unwrap().plane.is_none()); + assert!(project_face(&past, None, o, origin, Some(1000.0), light).unwrap().unwrap().plane.is_some()); + // Every face of a revolved profile of straight edges has one. + let (p, b) = cylinder(); + let faces = revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 35.0, ..Default::default() }).unwrap(); + assert!(!faces.is_empty() && faces.iter().all(|f| f.plane.is_some())); +} diff --git a/crates/three-d/src/visibility.rs b/crates/three-d/src/visibility.rs index 424242828b..3ed491ae10 100644 --- a/crates/three-d/src/visibility.rs +++ b/crates/three-d/src/visibility.rs @@ -303,6 +303,7 @@ mod tests { cap: false, visibility_pieces: None, surface: None, + plane: None, } } diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index d606f630d5..c870110434 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -55,6 +55,42 @@ fn selection_tool_with_selection(app: &VectorcraftApp) -> bool { && app.session.active().is_some_and(|st| !st.selection.is_empty()) } +/// Whether the bar shows its blank canvas actions, Draw (Pencil) and Place Image: a selection tool is +/// in use and the active document has no artwork, only layers and guides. +fn blank_canvas(app: &VectorcraftApp) -> bool { + fn no_art(nodes: &[std::sync::Arc]) -> bool { + nodes.iter().all(|n| n.is_guide() || (n.is_layer() && n.children().is_none_or(|c| no_art(c)))) + } + matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") + && app.session.active().is_some_and(|st| st.selection.is_empty() && no_art(&st.doc.layers)) +} + +/// Where the bar's top edge centers, on screen: under the lowest corner on screen of the selected +/// artboards or the selection, centered on them (the view may be rotated); on a blank canvas, which +/// has no selection, [`blank_canvas_anchor`]. None with nothing to sit under. +fn bar_anchor(app: &mut VectorcraftApp, xf: &Xf, on_artboards: bool, blank: bool) -> Option { + if blank { + return Some(blank_canvas_anchor(app, xf)); + } + let b = if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }?; + let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); + Some(pos2(xf.to_screen(b.center()).x, lowest)) +} + +/// Where the blank canvas bar's top edge centers, on screen: under the bottom of the active artboard +/// when part of it is in view, else the canvas's center. +fn blank_canvas_anchor(app: &VectorcraftApp, xf: &Xf) -> Pos2 { + let board = app.session.active().and_then(|st| { + let last = st.doc.artboards.len().checked_sub(1)?; + st.doc.artboards.get(app.view().map_or(0, |v| v.artboard).min(last)).map(|a| a.rect) + }); + let shown = board.map(|b| (b, xf.quad(b))).filter(|(_, q)| egui::Rect::from_points(q).intersects(xf.rect)); + match shown { + Some((b, q)) => pos2(xf.to_screen(b.center()).x, q.iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max)), + None => xf.rect.center(), + } +} + /// Whether a Type tool edits type, or has type selected: its bar then shows only the text /// formatting controls. fn type_tool_on_type(app: &VectorcraftApp) -> bool { @@ -75,6 +111,8 @@ pub(crate) fn task_bar_shows_type(app: &VectorcraftApp) -> bool { /// While a Type tool edits type, or has type selected, the bar shows only those controls and the /// Fill. When one row would be wider than the canvas, the font controls take a row of their own, /// narrowed to fit it, and the actions wrap onto rows under the handle's ([`bar_layout`]). +/// On a blank canvas, a document with no artwork and a selection tool in use, it sits under the +/// active artboard and offers Draw (Pencil) and Place Image. /// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the /// selection, pinned it stays put ([`crate::state::TaskBarPlace`]). pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { @@ -83,13 +121,15 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { // Text added with a Type tool shows its formatting controls, as the help page describes // (). let typing = type_tool_on_type(app); - if !(on_artboards || typing || selection_tool_with_selection(app)) { + // A blank canvas offers Draw (Pencil) and Place Image, as the same help page describes. + let blank = !on_artboards && blank_canvas(app); + if !(on_artboards || typing || blank || selection_tool_with_selection(app)) { return; } // Selected type has the font controls too, as the help page describes // (). let fonts = task_bar_shows_type(app); - let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return }; + let Some(anchor) = bar_anchor(app, xf, on_artboards, blank) else { return }; let Some(st) = app.session.active() else { return }; let n = st.selection.len(); let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)); @@ -137,6 +177,10 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none())); } items.push((tl!("Export"), "share-2", "file.exportForScreens", none())); + } else if blank { + // Draw (Pencil) switches to the Pencil tool; Place Image picks files for the Place dialog. + items.push((tl!("Draw (Pencil)"), "pencil", "tool.select", json!({"tool": "pencil"}))); + items.push((tl!("Place Image"), "image", "file.place", none())); } else if !typing { // The selection tools' object actions. A Type tool's bar leaves them out, Create Outlines // and the type conversions included. @@ -234,27 +278,20 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { let paints = chip_paints(app, ui.ctx()); let colored = has_colors(&st.doc, &st.selection.objects); let doc = st.uid; - // Under the selection's lowest corner on screen, centered on the selection (the view may be - // rotated). - let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); - let anchor = pos2(xf.to_screen(b.center()).x, lowest); let bounds = xf.rect.shrink(8.0); // The canvas's width less the frame's margins and stroke. let room = bounds.width() - BAR_FRAME; // On a canvas too narrow for the type controls at their narrowest, the bar leaves them out. let fonts = fonts && room >= crate::panels::character::task_bar_min_room(); // The selected objects' Fill, Stroke, Recolor Artwork (when they have colours to recolour) and - // Lock (a basic shape's properties instead), left out with the Artboard tool. A Type tool's bar - // has the Fill alone. - let chips: Vec = [ - (!on_artboards, Chip::Fill), - (!on_artboards && !typing, Chip::Stroke), - (!on_artboards && !typing && colored, Chip::Recolor), - (!on_artboards && !typing, Chip::Lock), - ] - .into_iter() - .filter_map(|(shown, chip)| shown.then_some(chip)) - .collect(); + // Lock (a basic shape's properties instead), left out with the Artboard tool and on a blank + // canvas, which have no selected objects. A Type tool's bar has the Fill alone. + let objects = !on_artboards && !blank; + let chips: Vec = + [(objects, Chip::Fill), (objects && !typing, Chip::Stroke), (objects && !typing && colored, Chip::Recolor), (objects && !typing, Chip::Lock)] + .into_iter() + .filter_map(|(shown, chip)| shown.then_some(chip)) + .collect(); let widths: Vec = items.iter().map(|(label, ..)| action_width(ui, label)).collect(); let font_w = fonts.then(|| { let w = crate::panels::character::TASK_BAR_WIDTHS; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 2a7162f9a1..4d609f2b5e 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -10333,7 +10333,7 @@ fn task_bar_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String /// With the Artboard tool, the Contextual Task Bar sits under the selected artboards and offers /// Resize, Duplicate, Rearrange All (with two or more artboards) and Export instead of the /// selected objects' actions. With the Selection tool it offers the objects' actions again, and -/// with nothing selected it doesn't show. +/// with nothing in the document the blank canvas actions. #[test] fn the_task_bar_offers_the_artboard_tools_actions() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -10341,7 +10341,7 @@ fn the_task_bar_offers_the_artboard_tools_actions() { app.select_tool("selection"); let ctx = egui::Context::default(); let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::>(); - assert!(names(&mut app).is_empty() && task_bar_rect(&ctx).is_none(), "nothing selected: no bar"); + assert_eq!(names(&mut app), ["Draw (Pencil)", "Place Image"], "a blank canvas"); app.select_tool("artboard"); crate::panels::artboards::select(&mut app, 1); diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 5ac4025c24..fc06926247 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -30,7 +30,8 @@ pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls { /// What the Control bar and the Properties panel offer for selected anchors ([`anchor_controls`]): /// "Convert:" corner or smooth, then "Anchors:" remove, connect (Join) and cut. Each group is a -/// row of its own: inline in the Control bar, one under the other in a panel. +/// row of its own: inline in the Control bar, one under the other in a panel. In the Control bar's +/// row, a group shows only while the row has room for its label and all its buttons. pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorControls) { let t = Tokens::get(ui.ctx()); let mut run = None; @@ -56,9 +57,14 @@ pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorCon AnchorControls::Convert => 1, AnchorControls::All => groups.len(), }; + let in_row = ui.layout().main_dir() == egui::Direction::LeftToRight; for (label, buttons) in groups.into_iter().take(shown) { + let text = egui::RichText::new(label).size(12.0).color(t.text); + if in_row && ui.available_width() < row_width(ui, &[widgets::text_width(ui, text.clone())]) + buttons_width(ui, buttons.len()) { + break; + } ui.horizontal(|ui| { - ui.label(egui::RichText::new(label).size(12.0).color(t.text)); + ui.label(text); for &(icon, tip, id, to) in buttons { if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() { run = Some((id, to.map_or_else(|| json!({}), |to| json!({ "to": to })))); @@ -78,6 +84,12 @@ pub(super) const ISOLATION_BUTTON: &str = "cb-isolation"; /// less, the menu is left out so those controls still fit the narrowest window. const ARTBOARD_SNAPPING_WIDTH: f32 = 900.0; +/// The width of [`snapping_menu`]'s button: its label, as a button draws it, and the button's padding. +fn snapping_width(ui: &Ui) -> f32 { + let label = egui::WidgetText::from(tl!("Snapping")).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button); + label.size().x + 2.0 * ui.spacing().button_padding.x +} + /// The Control bar's snapping quick access; every change uses its existing command, and Pixel /// Snapping Options… opens the dialog of `view.snapToPixel.options`. fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -115,9 +127,10 @@ fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) { /// while a symbol's art is the isolated layer. Otherwise, with one object selected that /// [`vectorcraft_tools::select::isolates`], it is Isolate Selected Object, which also isolates a /// path inside a group that Direct Selection selected. An envelope's contents get no button: the -/// envelope's Edit Envelope and Edit Contents buttons show instead. +/// envelope's Edit Envelope and Edit Contents buttons show instead. It is left out when the bar has +/// no room left for it. fn isolation_button(app: &mut VectorcraftApp, ui: &mut Ui) { - let Some(st) = app.session.active() else { return }; + let Some(st) = app.session.active().filter(|_| ui.available_width() >= BUTTON) else { return }; let (icon, tip, id) = if st.isolation.is_some() { if st.doc.pattern_edit.is_some() { return; @@ -151,9 +164,9 @@ const GLOBAL_EDIT_OPTIONS_WIDTH: f32 = 24.0; /// It opens the Properties panel's Global Edit Options, which set the same session options, and is /// off and closed while Global Edit is on, as that panel's dropdown is. It sits after Recolor /// Artwork, ahead of the X/Y/W/H fields that the bar leaves out when it runs short of room, and is -/// left out only when no room is left for it. -fn global_edit_options(app: &mut VectorcraftApp, ui: &mut Ui) { - if !crate::menus::enabled(app, "select.globalEdit") || ui.available_width() < GLOBAL_EDIT_OPTIONS_WIDTH { +/// left out when the room left, less `reserve` ([`transform_reserve`]), cannot hold it. +fn global_edit_options(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) { + if !crate::menus::enabled(app, "select.globalEdit") || ui.available_width() < row_width(ui, &[GLOBAL_EDIT_OPTIONS_WIDTH]) + reserve { return; } let on = app.session.active().is_some_and(|d| d.global_edit.is_some()); @@ -204,8 +217,27 @@ fn buttons_width(ui: &Ui, n: usize) -> f32 { (BUTTON + ui.spacing().item_spacing.x) * n as f32 } +/// The room controls `widths` wide take side by side in the bar's row, with the spacing after each. +fn row_width(ui: &Ui, widths: &[f32]) -> f32 { + widths.iter().map(|w| w + ui.spacing().item_spacing.x).sum() +} + +/// The size of the Control bar's Fill, Stroke and Graphic Style chips. +const PAINT_CHIP: f32 = 22.0; + +/// The width each of those chips takes with the chevron after it. +const CHIP_WIDTH: f32 = PAINT_CHIP + 16.0; + +/// The widths of the Control bar's stroke weight and Opacity fields. +const WEIGHT_FIELD_WIDTH: f32 = 100.0; +const OPACITY_FIELD_WIDTH: f32 = 56.0; + /// The separator that ends a group of the Control bar's controls, as [`group_end_width`] measures it. +/// It is left out when the bar has no room left for it. fn group_end(ui: &mut Ui) { + if ui.available_width() < group_end_width(ui) { + return; + } ui.add_space(GROUP_GAP); boundary(ui, separator); } @@ -256,8 +288,11 @@ fn boundary(ui: &mut Ui, draw: impl FnOnce(&mut Ui)) { } /// The separator that ends a group of the Control bar's controls, drawn as `ui.separator()` draws -/// one, and left out when no control follows it ([`boundary`]). +/// one, and left out when no control follows it ([`boundary`]) or the row has no room left for it. pub(crate) fn group_separator(ui: &mut Ui) { + if ui.layout().main_dir() == egui::Direction::LeftToRight && ui.available_width() < separator_width(ui) { + return; + } boundary(ui, |ui| { ui.separator(); }); @@ -298,20 +333,30 @@ fn end_width(app: &VectorcraftApp, ui: &Ui, nothing_selected: bool) -> f32 { } } +/// The room the Control bar's Fill and Stroke, Opacity, Graphic Style and Character groups leave +/// for the Transform link: the link and the gap and separator ahead of it, while something is +/// `selected` and the panel menu has the Transform group on, or none. Those groups show each control +/// only while this room is left after it, so the bar leaves them out before the link. +fn transform_reserve(app: &VectorcraftApp, ui: &Ui, selected: bool) -> f32 { + if selected && group_on(app, "transform") { group_end_width(ui) + crate::panels::transform::link_width(ui) } else { 0.0 } +} + /// Draws the Control bar's groups between its Character controls and the Transform link, and /// returns whether the Transform link and X/Y/W/H follow (false: nothing is selected, and Document /// Setup and Preferences, drawn here, end the bar, or the Transform group is off). The groups are /// the Paragraph section and area type's vertical alignment (#3759), then the Align buttons, the /// Align To menu and, for two or more objects, the Distribute buttons (a single object keeps the bar /// as it was); each shows only while the end of the bar keeps its room. Groups the panel menu has -/// off are left out, and take no room. -fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize) -> bool { +/// off are left out, and take no room. Without `room` (the Character group had none), all of these +/// groups are left out, so the bar trims them from its right end before the Character group. +fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize, room: bool) -> bool { let nothing = objects == 0; let end = end_width(app, ui, nothing); - if group_on(app, "paragraph") && crate::panels::paragraph::control_bar(app, ui, BUTTON, |ui| group_end_width(ui) + end) { + if room && group_on(app, "paragraph") && crate::panels::paragraph::control_bar(app, ui, BUTTON, |ui| group_end_width(ui) + end) { group_end(ui); } - if group_on(app, "verticalAlignment") + if room + && group_on(app, "verticalAlignment") && crate::panels::properties::vertical_align::shown(app).is_some() && ui.available_width() >= buttons_width(ui, 4) + group_end_width(ui) + end && crate::panels::properties::vertical_align::buttons(app, ui, BUTTON) @@ -319,11 +364,13 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize group_end(ui); } if nothing { + // Each button shows only while the bar has room left for it, so Preferences can show + // when Document Setup did not fit. if group_on(app, "documentOptions") { - if widgets::flat_button(ui, tl!("Document Setup"), DOCUMENT_SETUP_WIDTH).clicked() { + if ui.available_width() >= DOCUMENT_SETUP_WIDTH && widgets::flat_button(ui, tl!("Document Setup"), DOCUMENT_SETUP_WIDTH).clicked() { app.run("file.documentSetup", json!({})).ok(); } - if widgets::flat_button(ui, tl!("Preferences"), PREFERENCES_WIDTH).clicked() { + if ui.available_width() >= PREFERENCES_WIDTH && widgets::flat_button(ui, tl!("Preferences"), PREFERENCES_WIDTH).clicked() { app.run("edit.preferences", json!({})).ok(); } } @@ -331,7 +378,7 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize } let transform = group_on(app, "transform"); let end = if transform { separator_width(ui) + end } else { 0.0 }; - if group_on(app, "alignDistribute") && ui.available_width() >= buttons_width(ui, 7) + end { + if room && group_on(app, "alignDistribute") && ui.available_width() >= buttons_width(ui, 7) + end { crate::panels::align::align_buttons(app, ui, BUTTON); if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x + end { crate::panels::align::align_to_menu(app, ui, BUTTON); @@ -545,55 +592,80 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { /// The Control bar's Fill and Stroke group: the fill and stroke chips, the Stroke link, the weight /// and the width profile, and the separator after them while a control follows ([`group_separator`]). -fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui) { +/// Short of room, the bar leaves out the width profile first, then the Stroke link with the weight, +/// then the two chips; each shows only while the bar has room for all of it and `reserve` after it +/// ([`transform_reserve`]). +fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) { + if ui.available_width() < row_width(ui, &[CHIP_WIDTH, CHIP_WIDTH]) + reserve { + return; + } let shown_stroke = crate::panels::current_stroke(app); let mixed = crate::panels::stroke_mixed(app, ui.ctx()); let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed); - crate::panels::paint_chip(app, ui, false, 22.0, true); - crate::panels::paint_chip(app, ui, true, 22.0, true); + crate::panels::paint_chip(app, ui, false, PAINT_CHIP, true); + crate::panels::paint_chip(app, ui, true, PAINT_CHIP, true); // The link opens the Stroke panel as a popover under it; then the weight spinner // (with presets) and the width profile. - stroke_panel::link(app, ui, tl!("Stroke:")); - stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0); - if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) { - app.run("stroke.set", json!({"profile": id})).ok(); + let link = widgets::text_width(ui, stroke_panel::link_text(ui, tl!("Stroke:"))); + if ui.available_width() >= row_width(ui, &[link, WEIGHT_FIELD_WIDTH]) + reserve { + stroke_panel::link(app, ui, tl!("Stroke:")); + stroke_panel::weight_field(app, ui, "cb-stroke", weight, WEIGHT_FIELD_WIDTH); + if ui.available_width() >= row_width(ui, &[stroke_panel::PROFILE_DROPDOWN_WIDTH]) + reserve + && let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) + { + app.run("stroke.set", json!({"profile": id})).ok(); + } } - ui.add_space(4.0); - group_separator(ui); + group_end(ui); } /// The Control bar's Opacity group (its link and field, showing `opacity`) and, with something /// `selected`, its Graphic Style group (Style, Recolor Artwork and Global Edit Options), each while -/// the panel menu has it on, and the separator after them while a control follows. -fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: f32, selected: bool) { +/// the panel menu has it on, and the separator after them while a control follows. Short of room, +/// the bar leaves out Global Edit Options first, then Recolor Artwork, then the Style link with its +/// chip. The Opacity link with its field and the Style link with its chip each show only while the +/// bar has room for all of it and `reserve` after it ([`transform_reserve`]), so the narrower Style +/// part can show without the Opacity part. +fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: f32, selected: bool, reserve: f32) { let (opacity_on, style_on) = (group_on(app, "opacity"), group_on(app, "graphicStyle") && selected); + let mut drawn = false; if opacity_on { - // A click opens the Transparency panel; a drag scrubs the field (#4088). - if crate::scrub::link_label(ui, egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline()).clicked() { - app.ui.open_panel = Some("transparency".into()); - } - if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0) - && selected - { - app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok(); + let text = egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline(); + if ui.available_width() >= row_width(ui, &[widgets::text_width(ui, text.clone()), OPACITY_FIELD_WIDTH]) + reserve { + // A click opens the Transparency panel; a drag scrubs the field (#4088). + if crate::scrub::link_label(ui, text).clicked() { + app.ui.open_panel = Some("transparency".into()); + } + if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, OPACITY_FIELD_WIDTH) + && selected + { + app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok(); + } + drawn = true; } } - if style_on { + let text = egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline(); + if style_on && ui.available_width() >= GROUP_GAP + row_width(ui, &[widgets::text_width(ui, text.clone()), CHIP_WIDTH]) + reserve { // Style picker: the selection's graphic style; its menu applies another. - ui.add_space(4.0); - if ui.link(egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline()).clicked() { + ui.add_space(GROUP_GAP); + if ui.link(text).clicked() { app.ui.open_panel = Some("graphicStyles".into()); } - let resp = widgets::chip_button_named(ui, 22.0, true, tl!("Graphic Style"), |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r)); + let resp = + widgets::chip_button_named(ui, PAINT_CHIP, true, tl!("Graphic Style"), |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r)); egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui)); // Recolor Artwork, when the selection has colours to recolour. let colored = app.session.active().is_some_and(|st| crate::canvas::has_colors(&st.doc, &st.selection.objects)); - if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 24.0).clicked() { + if colored + && ui.available_width() >= buttons_width(ui, 1) + reserve + && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, BUTTON).clicked() + { crate::menus::invoke(app, "ui.recolorDialog", json!({})); } - global_edit_options(app, ui); + global_edit_options(app, ui, reserve); + drawn = true; } - if opacity_on || style_on { + if drawn { group_separator(ui); } } @@ -709,11 +781,16 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, } gripper(app, ui, t, bar, dock_room); if app.session.active().is_none() { - ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim)); + let text = egui::RichText::new(tl!("No Document")).color(t.text_dim); + if ui.available_width() >= widgets::text_width(ui, text.clone()) { + ui.label(text); + } return; } // The Artboard tool's controls fill the narrowest window, so they keep it to themselves there. - if group_on(app, "snapping") && (app.session.tool_id() != "artboard" || ui.available_width() >= ARTBOARD_SNAPPING_WIDTH) { + // Any other tool's bar shows the menu while it has room for it. + let snapping_room = if app.session.tool_id() == "artboard" { ARTBOARD_SNAPPING_WIDTH } else { snapping_width(ui) }; + if group_on(app, "snapping") && ui.available_width() >= snapping_room { snapping_menu(app, ui); } let Some(st) = app.session.active() else { return }; @@ -739,7 +816,10 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, _ => tl!(crate::panels::appearance::object_label(app)), }; let has_first = first.is_some(); - ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); + let label = egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text); + if ui.available_width() >= widgets::text_width(ui, label.clone()) { + ui.label(label); + } // With the Artboard tool in use, the bar shows the active artboard's controls in place // of the object controls. let (artboard_options, transform) = (group_on(app, "artboardOptions"), group_on(app, "transform")); @@ -755,7 +835,9 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, if object_options { isolation_button(app, ui); } - ui.add_space(6.0); + if ui.available_width() >= 6.0 { + ui.add_space(6.0); + } // An image or an Image Trace object shows its own controls in place of Fill and Stroke, // and leaves Fill and Stroke out while the Image Options group is off too. let image = if group_on(app, "imageOptions") { @@ -771,19 +853,26 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, crate::panels::symbols::control_bar(app, ui); } let opacity = if has_first { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 }; + let reserve = transform_reserve(app, ui, !sel.is_empty()); if !image && group_on(app, "fillStroke") { - fill_and_stroke(app, ui); + fill_and_stroke(app, ui, reserve); } - opacity_and_style(app, ui, t, opacity, !sel.is_empty()); - if group_on(app, "character") { - crate::panels::character::control_bar(app, ui); - } - if !groups_before_transform(app, ui, sel.len()) { + opacity_and_style(app, ui, t, opacity, !sel.is_empty(), reserve); + // The Character group shows whole, with the Transform link's room left after it, or not at + // all; left out, it leaves out the groups between it and the link too. + let room = !group_on(app, "character") || crate::panels::character::control_bar(app, ui, reserve); + if !groups_before_transform(app, ui, sel.len(), room) { erase_trailing(ui); return; } // The Transform link opens the whole Transform panel (reference point, rotate, - // shear, options) in a popover; X/Y/W/H follow inline while the bar has room. + // shear, options) in a popover; X/Y/W/H follow inline while the bar has room. The + // groups from Fill and Stroke on keep its room, but the controls ahead of them can + // still take it. + if ui.available_width() < crate::panels::transform::link_width(ui) { + erase_trailing(ui); + return; + } crate::panels::transform::link(app, ui); // The bounding box, rotated with rotated objects: its centre and its own sides. if let Some(b) = app.selection_box() diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 052c7ec230..be37f10a29 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -3037,6 +3037,127 @@ fn no_separator_is_left_at_the_end_of_the_control_bar() { assert!(stray.is_empty(), "a separator with nothing after it: {stray:#?}"); } +/// The shapes of the Control bar's controls drawn in window `screen` that reach past the left edge +/// of its panel menu button, or past the room a floating bar clips its controls to, or `None` when +/// the bar leaves the button out. +fn control_bar_overflow(app: &mut crate::VectorcraftApp, ctx: &egui::Context, screen: egui::Rect) -> Option> { + control_bar_frame_in(app, ctx, screen, vec![]); + let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui)); + let nodes: Vec<_> = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + let menu_left = nodes.iter().find(|n| n.label().is_some_and(|l| l == "Panel menu")).and_then(|n| n.bounds())?.x0 as f32; + // The bar's frame spans the whole bar, the panel menu button included. + let bar_width = if app.ui.control_bar_floating { screen.width().min(800.0) } else { screen.width() }; + let shapes = out.shapes.iter().filter_map(|cs| { + let r = cs.shape.visual_bounding_rect(); + let control = r.is_finite() && cs.clip_rect.intersects(r) && r.width() < bar_width - 4.0 && r.min.x < menu_left - 0.5; + let clipped = cs.clip_rect.right() < menu_left && r.right() > cs.clip_rect.right() + 0.5; + (control && (r.right() > menu_left + 0.5 || clipped)).then_some(r) + }); + Some(shapes.collect()) +} + +/// In a narrow window, the Control bar leaves out a control it has no room for, Fill and Stroke, +/// Opacity and Character included: every control it shows is drawn whole, left of the panel menu button, which +/// shows in every width, with the bar docked to the top or the bottom or floating. +#[test] +fn the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window() { + use crate::tests_removeanchors::{control_frame, has}; + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let mut faults = vec![]; + let mut check = |app: &mut crate::VectorcraftApp, case: &str| { + for (bottom, floating) in [(false, false), (true, false), (false, true)] { + (app.ui.control_bar_bottom, app.ui.control_bar_floating) = (bottom, floating); + for w in (150..=1500).step_by(9) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0)); + match control_bar_overflow(app, &ctx, screen) { + None => faults.push(format!("{case}, bottom {bottom}, floating {floating}, {w} wide: no panel menu")), + Some(over) if !over.is_empty() => faults.push(format!("{case}, bottom {bottom}, floating {floating}, {w} wide: {over:?}")), + Some(_) => continue, + } + break; + } + } + (app.ui.control_bar_bottom, app.ui.control_bar_floating) = (false, false); + }; + app.run("select.set", json!({ "ids": [id] })).unwrap(); + let bar = control_frame(&mut app, &ctx, vec![]); + assert!(has(&bar, "Stroke:") && has(&bar, "Opacity:"), "Fill and Stroke and Opacity show in a wide window: {bar:?}"); + check(&mut app, "a rectangle selected"); + app.select_tool("directSelection"); + app.run("select.anchors", json!({"id": id, "anchors": [[0, 0]]})).unwrap(); + check(&mut app, "an anchor selected"); + app.select_tool("selection"); + let text = app.run("text.create", json!({"x": 20, "y": 120, "text": "Point"})).unwrap()["id"].clone(); + app.run("select.set", json!({ "ids": [text] })).unwrap(); + let bar = control_frame(&mut app, &ctx, vec![]); + assert!(has(&bar, "Character:"), "the Character group shows in a wide window: {bar:?}"); + check(&mut app, "type selected"); + app.run("select.set", json!({ "ids": [] })).unwrap(); + check(&mut app, "nothing selected"); + assert!(faults.is_empty(), "a control drawn past the bar's room: {faults:#?}"); +} + +/// With Opacity and Graphic Style on, a bar too narrow for the Opacity link and its field still +/// shows the narrower Style link and its chip when it has room for them. +#[test] +fn a_narrow_control_bar_shows_the_style_link_without_the_opacity_field() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({ "ids": [id] })).unwrap(); + app.run("window.control.options", json!({"group": "fillStroke", "visible": false})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let shows = |nodes: &[egui::accesskit::Node], name: &str| nodes.iter().any(|n| n.label().is_some_and(|l| l == name)); + let (mut both, mut style_only) = (false, false); + for w in (150..=900).step_by(2) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0)); + control_bar_frame_in(&mut app, &ctx, screen, vec![]); + let nodes = control_bar_frame_in(&mut app, &ctx, screen, vec![]); + let (opacity, style) = (shows(&nodes, "Opacity:"), shows(&nodes, "Style:")); + both |= opacity && style; + style_only |= style && !opacity; + } + assert!(both, "a wide bar shows the Opacity and Style links"); + assert!(style_only, "some width has room for the Style link and its chip but not the Opacity link and its field"); +} + +/// With nothing selected, a bar too narrow for the Document Setup button still shows the narrower +/// Preferences button when it has room for it. +#[test] +fn a_narrow_control_bar_shows_preferences_without_document_setup() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.run("select.set", json!({ "ids": [] })).unwrap(); + for group in ["fillStroke", "opacity", "graphicStyle"] { + app.run("window.control.options", json!({"group": group, "visible": false})).unwrap(); + } + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let shows = |nodes: &[egui::accesskit::Node], name: &str| nodes.iter().any(|n| n.label().is_some_and(|l| l == name)); + let (mut both, mut preferences_only) = (false, false); + for w in (150..=900).step_by(2) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0)); + control_bar_frame_in(&mut app, &ctx, screen, vec![]); + let nodes = control_bar_frame_in(&mut app, &ctx, screen, vec![]); + let (setup, preferences) = (shows(&nodes, "Document Setup"), shows(&nodes, "Preferences")); + both |= setup && preferences; + preferences_only |= preferences && !setup; + } + assert!(both, "a wide bar shows Document Setup and Preferences"); + assert!(preferences_only, "some width has room for Preferences but not Document Setup"); +} + /// The Anchor Points group holds Convert and Anchors for direct-selected anchors; off, the bar keeps /// its Anchor Point label. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d9d89e0bb9..5afad21b0d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -153,7 +153,7 @@ pub use color_picker::open as open_color_picker; pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; pub(crate) use effect::{FILM_GRAIN_FIELDS, PAINT_DAUB_BRUSH_TYPE_LABELS, SHADES, shown_choices}; -pub use export_as::open as open_export_as; +pub use export_as::{export_selection, open as open_export_as}; pub(crate) use export_for_screens::drop_unread_screen_settings; pub use export_for_screens::open as open_export_for_screens; pub(crate) use export_for_screens::{ @@ -947,6 +947,8 @@ mod tests_accessibility; #[cfg(test)] mod tests_export; #[cfg(test)] +mod tests_export_selection; +#[cfg(test)] mod tests_import_pdf; #[cfg(test)] mod tests_package; diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index f34363e5b7..e807df7b01 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -26,9 +26,10 @@ const NUMBERS: &[&str] = &["size", "leading", "kerning", "tracking"]; /// The on, off or unset checkboxes. const SWITCHES: &[&str] = &["underline", "strikethrough", "standardVerticalRoman"]; /// The attributes of Advanced Character Formats, in the order the section shows them. -const ADVANCED: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "language"]; +const ADVANCED: &[&str] = + &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "tsume", "language"]; /// The number fields of Advanced Character Formats. -const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight"]; +const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "tsume"]; /// The Insert Aki Left and Right fields, which take the Character panel's presets and stop at /// [`vectorcraft_doc::text::MAX_AKI`]. const AKI: [&str; 2] = ["akiLeft", "akiRight"]; @@ -86,6 +87,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "rotation", "akiLeft", "akiRight", + "tsume", "language", "fill", "fill_tint", @@ -275,6 +277,7 @@ fn optional_number(d: &Dialog, key: &str) -> Result, String> { "rotation" => tl!("Character Rotation"), "akiLeft" => tl!("Insert Aki Left"), "akiRight" => tl!("Insert Aki Right"), + "tsume" => tl!("Tsume"), "fill_tint" => tl!("Fill Tint"), "stroke_tint" => tl!("Stroke Tint"), "stroke_width" => tl!("Weight"), @@ -322,6 +325,8 @@ fn optional_number(d: &Dialog, key: &str) -> Result, String> { // Insert Aki stops at 0 and at the largest value the style takes, as the Character // panel's fields do. let n = if AKI.contains(&key) { n.clamp(0.0, vectorcraft_doc::text::MAX_AKI) } else { n }; + // Tsume stops at 0% and 100% the same way. + let n = if key == "tsume" { n.clamp(0.0, vectorcraft_doc::text::MAX_TSUME) } else { n }; Ok(Some(json!(n))) } } @@ -600,7 +605,7 @@ pub(crate) fn reset_basic(d: &mut Dialog) { } } -/// Reset Panel in Advanced Character Formats: its nine attributes become unset. +/// Reset Panel in Advanced Character Formats: its ten attributes become unset. pub(crate) fn reset_advanced(d: &mut Dialog) { d.fields.insert("__resetAdvanced".into(), json!(true)); for key in ADVANCED_NUMBERS { @@ -755,8 +760,8 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { } /// Advanced Character Formats: Horizontal Scale, Vertical Scale, Baseline Shift, the Tate-chu-yoko -/// Up/Down and Left/Right, Character Rotation, Insert Aki Left and Right (with the Character -/// panel's presets) and Language, each empty (unset) or set. +/// Up/Down and Left/Right, Character Rotation, Insert Aki Left and Right and Tsume (with the +/// Character panel's presets) and Language, each empty (unset) or set. pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("character-style-advanced").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { for (key, caption, unit) in [ @@ -787,6 +792,17 @@ pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) { } ui.end_row(); } + let presets: Vec = crate::panels::character::TSUME_PRESETS.iter().map(f64::to_string).collect(); + let presets: Vec<&str> = presets.iter().map(String::as_str).collect(); + let row = ui.label(tl!("Tsume")); + let value = if d.fields.get("tsume").is_none_or(Value::is_null) { String::new() } else { d.str("tsume") }; + ui.horizontal(|ui| { + if let Some(v) = ui.label_inputs_by(row.id, |ui| crate::widgets::text_presets(ui, (KIND, "tsume"), &value, &presets, 160.0)) { + d.fields.insert("tsume".into(), json!(v)); + } + ui.label("%"); + }); + ui.end_row(); let row = ui.label(tl!("Language:")); let names: Vec<&str> = std::iter::once(" ").chain(vectorcraft_doc::setup::LANGUAGES.iter().map(|(name, _, _)| *name)).collect(); let current = d.fields.get("language").and_then(Value::as_str).unwrap_or("").to_string(); @@ -1847,6 +1863,50 @@ mod tests { assert!(t.runs.iter().all(|r| (r.style.aki_left, r.style.aki_right) == (250.0, 1000.0))); } + /// Tsume in Advanced Character Formats: empty is unset, an explicit 0 stays set, a typed value + /// stops at 0 and 100, Reset Panel unsets it, and the field is drawn and commits to the style + /// and the text set in it. + #[test] + fn tsume_field_shows_sets_resets_and_commits() { + let d = dialog_for(json!({"size": 12.0})); + assert_eq!(d.fields["tsume"], json!("")); + let mut d = dialog_for(json!({"tsume": 50.0})); + assert_eq!(d.fields["tsume"], json!(50.0)); + for (typed, kept) in [("0", 0.0), ("25.5", 25.5), ("150", 100.0), ("-5", 0.0)] { + d.fields.insert("tsume".into(), json!(typed)); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"tsume": kept})), "{typed}"); + } + d.fields.insert("tsume".into(), json!("")); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({}))); + d.fields.insert("tsume".into(), json!("tight")); + assert_eq!(edited_attrs(&d).unwrap_err(), "Tsume must be a number"); + let mut d = dialog_for(json!({"tsume": 50.0, "size": 12.0})); + d.fields.insert("section".into(), json!("advanced")); + reset_section(&mut d); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"size": 12.0}))); + + let mut app = app(); + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Text"})).unwrap()["id"].as_u64().unwrap(); + app.run("charStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + open(&mut app, "Body").unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 900.0)); + let mut d = app.ui.dialog.clone().unwrap(); + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| advanced_body(ui, &mut d)); + let texts = texts_of(&out); + out.textures_delta.clear(); + assert!(texts.iter().any(|(text, _)| text == "Tsume"), "Tsume is drawn: {texts:?}"); + app.ui.dialog.as_mut().unwrap().fields.insert("tsume".into(), json!("75")); + super::super::confirm(&mut app).unwrap(); + let state = app.session.doc().unwrap(); + assert_eq!(state.doc.char_styles[0].attrs, attrs(json!({"size": 24.0, "tsume": 75.0}))); + let vectorcraft_doc::NodeKind::Text(t) = &state.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(t.runs.iter().all(|r| r.style.tsume == 75.0)); + } + /// The negated tags of the twenty stylistic sets: an explicit empty Stylistic Sets choice. fn no_sets() -> Vec { vectorcraft_text::EXTRA_FEATURES[..20].iter().map(|s| format!("-{s}")).collect() diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs index 498d854fe9..2e0aefc54c 100644 --- a/crates/ui-egui/src/dialogs/dxf_options.rs +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -158,8 +158,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); ui.add_space(4.0); + // Hidden for Export Selection, which writes the selected art alone. let selection = app.session.active().is_some_and(|st| !st.selection.is_empty()); - if widgets::check(ui, tl!("Export Selected Art Only"), d.bool("selectedOnly") && selection, selection) { + if !d.bool(super::export_as::SELECTION_FIELD) + && widgets::check(ui, tl!("Export Selected Art Only"), d.bool("selectedOnly") && selection, selection) + { d.fields.insert("selectedOnly".into(), json!(!d.bool("selectedOnly"))); } check(ui, d, "alterPaths", tl!("Alter Paths for Appearance")); @@ -227,5 +230,5 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { app.ui.dxf_options = Value::Object(remembered.into_iter().flatten().collect()); app.ui.dialog = None; let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); - io::export(app, Some("dxf"), path, ¶ms) + io::export(app, Some("dxf"), path, &super::export_as::exported(d, params)) } diff --git a/crates/ui-egui/src/dialogs/eps_options.rs b/crates/ui-egui/src/dialogs/eps_options.rs index 3a20a6799b..8d34706df4 100644 --- a/crates/ui-egui/src/dialogs/eps_options.rs +++ b/crates/ui-egui/src/dialogs/eps_options.rs @@ -30,7 +30,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { }; /// Fields that are not remembered between exports (they belong to one export). -const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"]; +const PER_EXPORT: [&str; 8] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly", super::export_as::SELECTION_FIELD]; /// The options a first EPS Options dialog starts from (the export's defaults). fn defaults() -> Map { @@ -133,5 +133,5 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { Value::Object(d.fields.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect()); app.ui.dialog = None; let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); - io::export(app, Some("eps"), path, ¶ms) + io::export(app, Some("eps"), path, &super::export_as::exported(d, params)) } diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index bd9ee6ca49..add0fbed73 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -4,8 +4,10 @@ //! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG //! Options, Text Export Options, DXF Options, EPS Options, and Save PDF), //! else the file is written. DWG is listed, greyed out, with what to use instead. The formats of -//! installed exporter plug-ins come after the built-in ones: they write the whole document, and one -//! with parameters asks for them before writing (`plugin::open_export`). +//! installed exporter plug-ins come after the built-in ones: they write the whole document (from +//! Export Selection, the selection), and one with parameters asks for them before writing +//! (`plugin::open_export`). Export Selection ([`export_selection`]) takes the same route after its +//! save panel, and every format then writes the selection alone, cropped to it. use std::sync::LazyLock; @@ -144,12 +146,52 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { app.ui.dialog = None; let path = io::export_path(app, None, f)?; let range = if all { "all".to_string() } else { range }; - let r = options_or_write(app, f, plugin, path, use_artboards, all, range); + let r = options_or_write(app, f, plugin, path, Pages { use_artboards, all, range, selection: false }); // The options follow the save panel: the Enter that closed it isn't theirs (as in Save As). super::opened_by_panel(app); r } +/// The options dialogs' field that is true when Export Selection opened them: OK then writes the +/// selection alone ([`exported`]). Not remembered with the dialog's options. +pub(super) const SELECTION_FIELD: &str = "__selection"; + +/// `params` of an options dialog `d` for [`io::export`]: with [`io::EXPORT_SELECTION`] when +/// Export Selection opened `d`. +pub(super) fn exported(d: &Dialog, mut params: Value) -> Value { + if d.bool(SELECTION_FIELD) + && let Some(o) = params.as_object_mut() + { + o.insert(io::EXPORT_SELECTION.into(), json!(true)); + } + params +} + +/// File › Export Selection (`document.exportSelection` without params): the save panel picks the +/// file and its format, then the format's options dialog follows as in Export As, else the file is +/// written. Each route writes the selected objects alone, cropped to them. Nothing selected to +/// export stops before the save panel. +pub fn export_selection(app: &mut VectorcraftApp) -> Result { + let st = app.session.active().ok_or("no document")?; + fileio::selection_doc(st, "document.exportSelection", false).map_err(|e| e.to_string())?; + let path = io::selection_path(app)?; + let f = fileio::writable_format(None, Some(&path))?; + let plugin = vectorcraft_plugins::export::by_format(f.id); + let pages = Pages { use_artboards: false, all: true, range: String::new(), selection: true }; + let r = options_or_write(app, f, plugin, path, pages); + super::opened_by_panel(app); + r +} + +/// What an export writes: with `use_artboards` the artboards (`all`, else the 1-based `range`), +/// else the bounds of the visible art, or with `selection` the selection alone. +struct Pages { + use_artboards: bool, + all: bool, + range: String, + selection: bool, +} + /// After the save panel picked `path`: the options dialog of format `f` (or of its exporter /// plug-in), else the file written. fn options_or_write( @@ -157,11 +199,10 @@ fn options_or_write( f: &'static Format, plugin: Option>, path: String, - use_artboards: bool, - all: bool, - range: String, + pages: Pages, ) -> Result { - match f.id { + let Pages { use_artboards, all, range, selection } = pages; + let r = match f.id { "svg" | "svgz" => { svg_options::open(app, svg_options::Mode::Export, Some(&path)); if let Some(o) = app.ui.dialog.as_mut() { @@ -169,24 +210,33 @@ fn options_or_write( } Ok(Value::Null) } - // Save PDF writes the artboards as pages, or one page of the art's bounds. + // Save PDF writes the artboards as pages, or one page of the art's bounds or the selection. "pdf" if use_artboards => save_pdf::open(app, &json!({ "path": path, "range": (!all).then_some(range) })), "pdf" => save_pdf::open(app, &json!({ "path": path, "useArtboards": false })), "txt" => { text_export::open(app, &path); + if let Some(o) = app.ui.dialog.as_mut().filter(|_| selection) { + o.fields.insert("selectionOnly".into(), json!(true)); + } Ok(Value::Null) } _ => { let mut params = json!({ "format": f.id, "path": path }); // A format that holds the whole document (a template) has no Use Artboards: the // export is not given the option, which its warnings would name as not used. - if plugin.is_some() || f.options.iter().any(|o| o.name == "useArtboards") { + if !selection && (plugin.is_some() || f.options.iter().any(|o| o.name == "useArtboards")) { params["useArtboards"] = json!(use_artboards); if use_artboards { params["range"] = json!(range); } } - if let Some(plugin) = plugin.filter(|p| !p.manifest().params.is_empty()) { + let plugin = plugin.filter(|p| !p.manifest().params.is_empty()); + let dialog = plugin.is_none() && (f.raster || matches!(f.id, "dxf" | "eps")); + if selection { + // An options dialog keeps the mark as a field of its own, which OK hands on. + params[if dialog { SELECTION_FIELD } else { io::EXPORT_SELECTION }] = json!(true); + } + if let Some(plugin) = plugin { plugin::open_export(app, &plugin, params); return Ok(Value::Null); } @@ -202,7 +252,12 @@ fn options_or_write( eps_options::open(app, ¶ms); return Ok(Value::Null); } - io::export(app, Some(f.id), Some(path), ¶ms) + return io::export(app, Some(f.id), Some(path), ¶ms); } + }; + // Export Selection's mark on the options dialog that opened. + if let Some(o) = app.ui.dialog.as_mut().filter(|_| selection) { + o.fields.insert(SELECTION_FIELD.into(), json!(true)); } + r } diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 04257bc64d..98d6225c17 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -53,7 +53,11 @@ fn format_of(d: &Dialog) -> Option<&'static Format> { /// Open the options of raster format `f` for an export whose params (format, path, artboard /// choice) are `params`. pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) { - let size = app.session.active().and_then(|st| export_size(&st.doc, ¶ms)); + let selection = params[super::export_as::SELECTION_FIELD] == true; + let size = app.session.active().and_then(|st| match selection { + true => vectorcraft_engine::cmd::fileio::selection_doc(st, "document.exportSelection", false).ok().map(|(_, r)| (r.width(), r.height())), + false => export_size(&st.doc, ¶ms), + }); if let Some(o) = params.as_object_mut() { o.insert("ppi".into(), json!(72)); o.extend(defaults(app, f)); @@ -340,5 +344,5 @@ pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, ids: &[&str], fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (format, path) = (d.str("format"), d.str("path")); app.ui.dialog = None; - io::export(app, Some(&format), Some(path), &form::params(d)) + io::export(app, Some(&format), Some(path), &super::export_as::exported(d, form::params(d))) } diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 2e19375994..225c2cee52 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -6,7 +6,8 @@ //! `ui.dialog.set`. OK runs `document.exportPdf` through [`io::export_pdf`]; the Summary is //! `document.pdfSettings`. Save Preset… names the settings as a preset (`pdf.preset.save`). //! Opened from Export As with Use Artboards off (`__artBounds`), the dialog has no Artboards -//! section, and OK writes one page of the visible art's bounds through [`io::export`]. +//! section, and OK writes one page of the visible art's bounds through [`io::export`]; opened +//! from Export Selection (`__selection` too), the page is the selection's crop. //! //! The same dialog edits a preset (kind `pdfPreset`, from Edit → PDF Presets): a `name` and //! `description` instead of the preset and artboards, and OK saves the preset. @@ -154,7 +155,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } let r = match SaveMode::of(&d.str("__save")) { Some(mode) => io::save_pdf(app, mode, params(d)), - None if d.bool(ART_BOUNDS) => export_art(app, params(d)), + None if d.bool(ART_BOUNDS) => export_art(app, d), None => io::export_pdf(app, params(d)), }; // A question the write asked (replace the file the document was opened from?) stays open. @@ -164,12 +165,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { r } -/// A page of the art's bounds (Export As with Use Artboards off): written as Export As writes -/// other formats ([`io::export`]), with the dialog's options and a saved preset written out. -fn export_art(app: &mut VectorcraftApp, mut p: Value) -> Result { +/// A page of the art's bounds (Export As with Use Artboards off), or of the selection's crop +/// (Export Selection): written as Export As writes other formats ([`io::export`]), with the +/// dialog's options and a saved preset written out. +fn export_art(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut p = params(d); let path = p.as_object_mut().and_then(|o| o.remove("path")).and_then(|v| v.as_str().map(str::to_string)); let p = pdf::expand_preset(&app.session, "file.exportAs", &p).map_err(|e| e.to_string())?.into_owned(); - io::export(app, Some("pdf"), path, &p) + io::export(app, Some("pdf"), path, &super::export_as::exported(d, p)) } /// The settings as `pdf.preset.save` params (without the artboards and path). diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index ac568dbace..b477bb35c0 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -129,15 +129,23 @@ fn options(d: &Dialog) -> Value { fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let opts = options(d); - if Mode::of(d) == Mode::Export && d.bool("useArtboards") { + let selection = d.bool(super::export_as::SELECTION_FIELD); + if Mode::of(d) == Mode::Export && d.bool("useArtboards") && !selection { let n = app.session.active().map_or(0, |st| st.doc.artboards.len()); let pick: vectorcraft_engine::cmd::fileio::ArtboardPick = serde_json::from_value(opts.clone()).map_err(|e| e.to_string())?; pick.resolve(n)?; } - // Remembered for next time, without the document's artboard range. + // Remembered for next time, without the document's artboard range. Export Selection shows no + // Use Artboards, so the one remembered before stays. let mut last = opts.clone(); if let Some(o) = last.as_object_mut() { o.remove("range"); + if selection { + match app.ui.svg_options.get("useArtboards") { + Some(v) => o.insert("useArtboards".into(), v.clone()), + None => o.remove("useArtboards"), + }; + } } app.ui.svg_options = last; app.ui.dialog = None; @@ -145,6 +153,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if let Some(p) = d.fields.get("path").filter(|p| p.is_string()) { params["path"] = p.clone(); } + let params = super::export_as::exported(d, params); let id = match Mode::of(d) { Mode::Export => "file.export.svg", Mode::Save => "file.saveAs", @@ -177,8 +186,11 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, enabled: boo fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { option_fields(ui, d, false); + // Export Selection writes the selection's crop, without artboards to choose. if Mode::of(d) == Mode::Export { - artboards(app, ui, d); + if !d.bool(super::export_as::SELECTION_FIELD) { + artboards(app, ui, d); + } } else { check(ui, d, "preserveEditing", tl!("Preserve Editing Capabilities"), true); } diff --git a/crates/ui-egui/src/dialogs/tests_export_selection.rs b/crates/ui-egui/src/dialogs/tests_export_selection.rs new file mode 100644 index 0000000000..15fe559974 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_export_selection.rs @@ -0,0 +1,238 @@ +//! Export Selection (`document.exportSelection` without params): the picked format's options +//! dialog follows the save panel, as in Export As, and writes the selection alone. + +use std::cell::Cell; +use std::rc::Rc; + +use serde_json::json; +use vectorcraft_plugins::wat::{self as tpl, Echo}; + +use super::tests_export::{Written, app_saving, frame, kind, names, set}; +use super::*; + +/// The last file written, as text. +fn last_text(written: &Written) -> String { + String::from_utf8_lossy(&written.borrow().last().unwrap().1).into_owned() +} + +/// The width and height in a PNG's header. +fn png_size(png: &[u8]) -> (u32, u32) { + let n = |at: usize| u32::from_be_bytes([png[at], png[at + 1], png[at + 2], png[at + 3]]); + (n(16), n(20)) +} + +/// Pick `path` in the next save panels. +fn picking(app: &mut VectorcraftApp, path: &str) { + let picked = path.to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); +} + +/// How many paths a native document's JSON (or the input an exporter plug-in is given) holds. +fn paths_in(bytes: &[u8]) -> usize { + String::from_utf8_lossy(vectorcraft_format::json_part(bytes)).matches("\"anchors\"").count() +} + +/// A PDF continues in Save PDF, without its artboard choice, and its options apply: the page is +/// the selected 10 m rectangle's crop (not the art's bounds, which take in the ellipse), with a +/// `UserUnit` once "Write pages longer than 200 in at full size in points" is turned off. +#[test] +fn export_selection_to_pdf_continues_in_save_pdf() { + let (mut app, written) = app_saving(2, |_| "/out/sel.pdf".into()); + app.run("shape.rectangle", json!({"x": 0, "y": 100, "width": 28_346.46, "height": 50})).unwrap(); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some("savePdf"), "the PDF options follow"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.str("path"), "/out/sel.pdf"); + assert!(d.bool("__artBounds") && d.bool(export_as::SELECTION_FIELD)); + assert!(written.borrow().is_empty(), "nothing is written before OK"); + set(&mut app, "longPagesInPoints", json!(false)); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["path"], "/out/sel.pdf"); + assert!(app.ui.dialog.is_none()); + let bytes = written.borrow()[0].1.clone(); + assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit"), "the dialog's option applies"); + let pdf = vectorcraft_pdf::import(&bytes).unwrap(); + assert_eq!(pdf.artboards.len(), 1); + let page = pdf.artboards[0].rect; + assert!((28_346.0..28_350.0).contains(&page.width()), "{}", page.width()); + assert_eq!(page.height().round(), 50.0, "the selection's crop"); +} + +/// Every format with an options dialog shows it after the save panel, and OK writes the +/// selection alone, cropped to the selected 10×5 pt rectangle: the PNG at 72 ppi, the SVG's view +/// box, the EPS bounding box and the DXF's extents. The mark is not kept with the options +/// remembered for the next export. +#[test] +fn export_selection_continues_in_each_format_s_options() { + let (mut app, written) = app_saving(2, |_| String::new()); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + for (ext, options) in [("svg", "svgOptions"), ("png", "pngOptions"), ("eps", "epsOptions"), ("dxf", "dxfOptions")] { + let path = format!("/out/sel.{ext}"); + picking(&mut app, &path); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some(options), "{ext}"); + assert!(app.ui.dialog.as_ref().unwrap().bool(export_as::SELECTION_FIELD), "{ext}"); + if ext == "png" { + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__size"], json!([10.0, 5.0]), "the selection's size"); + } + frame(&mut app); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "{ext}"); + assert_eq!(names(&written).last(), Some(&path), "{ext}"); + let text = last_text(&written); + match ext { + "png" => assert_eq!(png_size(&written.borrow().last().unwrap().1), (10, 5), "10×5 pt at 72 ppi"), + "svg" => assert!(text.contains(r#"viewBox="0 0 10 5""#), "{text}"), + "eps" => assert!(text.contains("%%BoundingBox: 0 0 10 5"), "{text}"), + _ => { + let at = text.find("$EXTMAX").unwrap_or_else(|| panic!("no extents: {text}")); + let max: Vec = text[at..].lines().skip(1).collect::>().chunks(2).take(2).map(|c| c[1].trim().parse().unwrap()).collect(); + // In millimeters, DXF Options' default unit: 10×5 pt. + assert!((max[0] - 10.0 * 25.4 / 72.0).abs() < 1e-3 && (max[1] - 5.0 * 25.4 / 72.0).abs() < 1e-3, "the selection's extents: {max:?}"); + } + } + } + assert_eq!(names(&written).len(), 4, "one file each"); + assert!(app.ui.eps_options.get(export_as::SELECTION_FIELD).is_none(), "EPS Options does not remember the mark"); + assert!(app.ui.svg_options.get(export_as::SELECTION_FIELD).is_none()); +} + +/// Text Export Options opens with Selection Only on, and OK writes the selected type alone. +#[test] +fn export_selection_to_text_writes_the_selected_type() { + let (mut app, written) = app_saving(1, |_| "/out/sel.txt".into()); + app.run("text.create", json!({"x": 10, "y": 10, "text": "Left out"})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 30, "text": "Selected"})).unwrap(); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some("txtOptions")); + assert!(app.ui.dialog.as_ref().unwrap().bool("selectionOnly")); + frame(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/out/sel.txt"]); + assert_eq!(last_text(&written), "Selected\n"); +} + +/// SVG Options opened by Export Selection hides Use Artboards, and its OK leaves the Use +/// Artboards setting that the next Export As SVG starts from as it was. +#[test] +fn export_selection_to_svg_keeps_the_remembered_use_artboards() { + let (mut app, _) = app_saving(2, |_| "/out/sel.svg".into()); + app.run("file.export.svg", json!({})).unwrap(); + set(&mut app, "useArtboards", json!(true)); + confirm(&mut app).unwrap(); + assert_eq!(app.ui.svg_options["useArtboards"], true); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some("svgOptions")); + confirm(&mut app).unwrap(); + assert_eq!(app.ui.svg_options["useArtboards"], true, "{}", app.ui.svg_options); +} + +/// A format without an options dialog (a native document) and an exporter plug-in, with or +/// without parameters, are given the selection alone: fewer paths than the whole document. +#[test] +fn export_selection_without_a_dialog_and_to_plug_ins_writes_the_selection() { + let (mut app, written) = app_saving(1, |_| String::new()); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + app.run("file.exportAs", json!({"path": "/out/all.vectorcraft"})).unwrap(); + let all = paths_in(&written.borrow().last().unwrap().1); + picking(&mut app, "/out/sel.vectorcraft"); + app.run("document.exportSelection", json!({})).unwrap(); + assert!(app.ui.dialog.is_none(), "written at once"); + assert_eq!(names(&written).last().map(String::as_str), Some("/out/sel.vectorcraft")); + let selected = paths_in(&written.borrow().last().unwrap().1); + assert!(selected >= 1 && selected < all, "{selected} of {all} paths"); + + for (id, ext, params) in + [("test.ui.selwhole", "tsw", "{}"), ("test.ui.selparams", "tsp", r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#)] + { + let wasm = wat::parse_str(tpl::echo_exporter(&tpl::exporter(id, &format!(r#"["{ext}"]"#), params), Echo::Input)).unwrap(); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap(); + picking(&mut app, &format!("/out/sel.{ext}")); + app.run("document.exportSelection", json!({})).unwrap(); + if params != "{}" { + assert_eq!(kind(&app), Some(plugin::EXPORT_KIND), "{id} asks for its parameters"); + frame(&mut app); + confirm(&mut app).unwrap(); + } + assert!(app.ui.dialog.is_none(), "{id}"); + assert_eq!(names(&written).last(), Some(&format!("/out/sel.{ext}"))); + let given = paths_in(&written.borrow().last().unwrap().1); + app.run("plugin.remove", json!({"id": id})).unwrap(); + assert_eq!(given, selected, "{id} is given the selection"); + } +} + +/// The question before replacing the file the document was opened from runs the export again as +/// an Export Selection: the PNG written after OK is the selection's. +#[test] +fn export_selection_after_the_question_before_losing_writes_the_selection() { + let (mut app, written) = app_saving(1, |_| "/out/sel.png".into()); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + let st = app.session.doc_mut().unwrap(); + st.imported_from = Some("/out/sel.png".into()); + st.import_losses = vec!["hidden type".into()]; + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some("pngOptions")); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["pending"], super::confirm::KIND); + assert!(written.borrow().is_empty(), "nothing is written before OK"); + confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/out/sel.png"]); + assert_eq!(png_size(&written.borrow()[0].1), (10, 5), "the selection's crop"); +} + +/// `file.exportAs {path, exportSelection: true}` writes the selection without a dialog; a value +/// that is not true or false is refused. +#[test] +fn export_as_with_export_selection_writes_the_selection() { + let (mut app, written) = app_saving(1, |_| String::new()); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + app.run("file.exportAs", json!({"path": "/out/sel.png", "exportSelection": true, "useArtboards": true})).unwrap(); + assert_eq!(png_size(&written.borrow()[0].1), (10, 5)); + let e = app.run("file.exportAs", json!({"path": "/out/sel.png", "exportSelection": "yes"})).unwrap_err(); + assert!(e.contains("exportSelection must be true or false"), "{e}"); + assert_eq!(names(&written).len(), 1); +} + +/// Appearance of Black › Printing / Exporting set to rich black draws a CMYK black as RGB black in +/// a PNG from Export As and from Export Selection, as `document.export` draws it. +#[test] +fn rich_black_reaches_files_from_the_export_dialogs() { + let (mut app, written) = app_saving(1, |_| "/out/sel.png".into()); + app.run("file.new", json!({"width": 40, "height": 30, "colorMode": "cmyk"})).unwrap(); + app.run("paint.setStroke", json!({"none": true})).unwrap(); + app.run("paint.setFill", json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap(); + let px = |w: &Written| { + let w = w.borrow(); + image::load_from_memory(&w.last().unwrap().1).unwrap().to_rgba8().get_pixel(10, 10).0 + }; + app.run("file.exportAs", json!({"path": "/out/all.png"})).unwrap(); + let accurate = px(&written); + assert!(accurate[0] > 10, "accurate: dark grey, {accurate:?}"); + app.session.prefs.black_output = "rich".into(); + app.run("file.exportAs", json!({"path": "/out/all.png"})).unwrap(); + assert_eq!(px(&written), [0, 0, 0, 255], "Export As"); + app.run("document.exportSelection", json!({})).unwrap(); + frame(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(names(&written).last().map(String::as_str), Some("/out/sel.png")); + assert_eq!(px(&written), [0, 0, 0, 255], "Export Selection"); +} + +/// With nothing selected, Export Selection stops before the save panel. +#[test] +fn nothing_selected_stops_before_the_save_panel() { + let (mut app, written) = app_saving(1, |_| "/out/sel.pdf".into()); + app.run("select.none", json!({})).unwrap(); + let asked = Rc::new(Cell::new(false)); + let a = asked.clone(); + app.services.pick_save = Some(Box::new(move |_| { + a.set(true); + Some("/out/sel.pdf".into()) + })); + let e = app.run("document.exportSelection", json!({})).unwrap_err(); + assert!(e.contains("select something to export"), "{e}"); + assert!(!asked.get() && app.ui.dialog.is_none() && written.borrow().is_empty()); +} diff --git a/crates/ui-egui/src/dialogs/text_export.rs b/crates/ui-egui/src/dialogs/text_export.rs index 6701fd65d7..12e7b5d425 100644 --- a/crates/ui-egui/src/dialogs/text_export.rs +++ b/crates/ui-egui/src/dialogs/text_export.rs @@ -1,5 +1,6 @@ -//! Export As → Text: the Text Export Options (encoding, line endings, selection only) for a -//! picked `.txt` path. Fields are `document.export` params (`format`, `path`…). +//! Export As → Text and Export Selection to `.txt`: the Text Export Options (encoding, line +//! endings, selection only) for a picked `.txt` path. Fields are `document.export` params +//! (`format`, `path`…). use serde_json::{Value, json}; @@ -39,14 +40,25 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.end_row(); }); ui.add_space(8.0); - let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()); + // Export Selection keeps Selection Only on. + let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()) && !d.bool(super::export_as::SELECTION_FIELD); ui.add_enabled_ui(selected, |ui| form::check(ui, d, "selectionOnly", tl!("Selection Only"))); false } -/// Write the text file (the command runs first, so a bad option keeps the dialog open). +/// Write the text file (the command runs first, so a bad option keeps the dialog open). Opened by +/// Export Selection, it writes `document.exportSelection`'s text. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let path = io::save_command_output(app, "document.export", "txt", form::params(d))?; + let mut params = form::params(d); + let id = if d.bool(super::export_as::SELECTION_FIELD) { + if let Some(o) = params.as_object_mut() { + o.remove("selectionOnly"); + } + "document.exportSelection" + } else { + "document.export" + }; + let path = io::save_command_output(app, id, "txt", params)?; app.ui.dialog = None; Ok(json!({ "path": path })) } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index cad5b53d8e..8db6de2f96 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -698,7 +698,7 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { if depth <= 1 || quoted(v) { v.to_string() } else if v.starts_with(['{', '[']) { - lang.catalog().exact_message(v).unwrap_or(v).to_string() + exact_translation(lang.catalog(), v).unwrap_or_else(|| v.to_string()) } else { message_at(lang, v, depth - 1) } @@ -708,6 +708,12 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { fill_once(template, &args) } +/// The translation of a message with an exact `@msg` row in `catalog`, its `{{` and `}}` shown as +/// `{` and `}`. +fn exact_translation(catalog: &Catalog, s: &str) -> Option { + catalog.exact_message(s).map(|tr| fill_once(tr, &[])) +} + /// Fill `{name}` placeholders in one pass: a value that holds `{name}` text stays as it is. /// Unknown placeholders are left as written. `{{` and `}}` are a literal `{` and `}`. pub(crate) fn fill_once(template: &str, args: &[(&str, &str)]) -> String { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4b5982cecd..8cb531e411 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -691,6 +691,8 @@ font-technology Variable Променливи Crop Image Изрязване на изображение Image Tools Инструменти за изображения Remove Background… Премахване на фона… + Draw (Pencil) Рисуване (Молив) + Place Image Вмъкване на изображение Remove Background Премахване на фона Background Фон Edge Color Цвят на ръба @@ -2586,6 +2588,7 @@ font-technology Variable Променливи Proportional Metrics Пропорционални метрики Insert Aki Left Вмъкване на аки отляво Insert Aki Right Вмъкване на аки отдясно + Tsume Цуме Character Direction Посока на знаците Digits Цифри Arabic Арабски diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 1576b72373..57790c64c6 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -201,6 +201,8 @@ Crop Image Izreži sliku Image Tools Alati za slike Remove Background… Ukloni pozadinu… + Draw (Pencil) Crtaj (olovka) + Place Image Postavi sliku Remove Background Ukloni pozadinu Background Pozadina Edge Color Boja ruba @@ -1571,6 +1573,7 @@ lighting Light Svjetlo Proportional Metrics Proporcionalna metrika Insert Aki Left Umetni aki lijevo Insert Aki Right Umetni aki desno + Tsume Tsume Character Direction Smjer znakova Digits Cifre Arabic Arapski @@ -6488,7 +6491,7 @@ anchor Points: Tačke: Stop on Errors Zaustavi pri greškama Yonroku-ban Yonroku-ban @msg image '{key}' has no pixels and was left out slika '{key}' nema piksela i izostavljena je -@msg not an RTF file: it doesn't start with {\\rtf nije RTF datoteka: ne počinje s {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf nije RTF datoteka: ne počinje s {{\\rtf @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG ovo je PostScript datoteka (.ai sačuvan u starijem formatu ili bez PDF kompatibilnosti, ili EPS datoteka), koja se još ne može otvoriti: sačuvajte je kao PDF, .ai kompatibilan s PDF-om ili SVG font-script Japanese Japansko swatch [None] [Nijedna] diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 99ecf7dd95..592de77eed 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1006,6 +1006,8 @@ antiAlias Sharp Nítid Crop Image Retalla la imatge Image Tools Eines d’imatge Remove Background… Elimina el fons… + Draw (Pencil) Dibuixa (llapis) + Place Image Col·loca una imatge Remove Background Elimina el fons Background Fons Edge Color Color de la vora @@ -3356,6 +3358,7 @@ digits Malayalam Malaiàlam Proportional Metrics Mètriques proporcionals Insert Aki Left Insereix aki a l'esquerra Insert Aki Right Insereix aki a la dreta + Tsume Tsume Proportional Lining Xifres proporcionals alineades Position: Posició: Default Position Posició predeterminada @@ -5931,7 +5934,7 @@ digits Malayalam Malaiàlam @msg not an HP-GL file no és un fitxer HP-GL @msg not a PICT file no és un fitxer PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` ha de ser una matriu d’1 a 255 colors #rrggbb -@msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf no és un fitxer RTF: no comença amb {{\\rtf @msg not found no s’ha trobat @msg not in pattern editing mode no esteu en mode d’edició de motius @msg finish editing the pattern first acabeu primer l’edició del motiu @@ -6496,8 +6499,8 @@ digits Malayalam Malaiàlam @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un exportador declara d’1 a {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` com a màxim {MAX_UNGROUPED} paràmetres fora de grups: doneu un `group` als altres @msg can't encode the input no es pot codificar l’entrada -@msg expected {"dataBase64"} s’esperava {"dataBase64"} -@msg expected {"objects"} s’esperava {"objects"} +@msg expected {{"dataBase64": "…"}} s’esperava {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} s’esperava {{"objects": [...]}} @msg export `{name}`: {e} exportació del mòdul `{name}`: {e} @msg extension {e} is listed twice l’extensió {e} apareix dues vegades @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensió {e}: feu servir 1-16 lletres minúscules i dígits, sense el punt @@ -6561,11 +6564,11 @@ digits Malayalam Malaiàlam @msg a painted live effect returns only `objects` un efecte dinàmic de pintura només retorna `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una peça d’un efecte dinàmic de pintura només té `path`, `fillRule`, `fills` i `strokes` @msg a read-only object was left out s’ha omès un objecte de només lectura -@msg a read-only object was left out: return it, as {"id"} alone or as it was given s’ha omès un objecte de només lectura: retorneu-lo com a {"id"} sol o tal com s’ha rebut +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given s’ha omès un objecte de només lectura: retorneu-lo com a {{"id": …}} sol o tal com s’ha rebut @msg a string una cadena @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un traç només té `paint`, `width`, `join`, `cap` i `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un traç ha de ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} un efecte ha de ser {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} un traç ha de ser {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} un efecte ha de ser {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` un efecte en un farciment només retorna `fills` @msg an effect on a stroke returns only `strokes` un efecte en un traç només retorna `strokes` @msg an object must be a JSON object un objecte ha de ser un objecte JSON diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index ab3abff6f5..f2fab1fcfa 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -13,7 +13,8 @@ pub struct Catalog { ids: HashMap, /// plural messages: `one|other` → forms plurals: HashMap>, - /// status and error messages without placeholders (`@msg`): English → translation + /// status and error messages without placeholders (`@msg`): English, its `{{` and `}}` read as + /// `{` and `}` → translation, as written messages: HashMap, /// status and error messages with `{name}` placeholders (`@msg`, and each English form of a /// `@msgplural` row), most specific first @@ -193,13 +194,20 @@ impl Catalog { "@plural" => { c.plurals.insert(src, tr.split('|').map(str::to_string).collect()); } - "@msg" => { - if src.contains('{') || src.contains("}}") { - c.templates.extend(Template::parse(&src, vec![tr], false)); - } else { - c.messages.insert(src, tr); - } + "@msg" if !src.contains(['{', '}']) => { + c.messages.insert(src, tr); } + "@msg" => match Template::parse(&src, vec![tr], false) { + // A row without placeholders is an exact message, under its text with `{{` and + // `}}` read as literal braces. + Some(Template { pieces, mut forms, .. }) if matches!(pieces.as_slice(), [Piece::Text(_)]) => { + if let (Some(Piece::Text(text)), Some(tr)) = (pieces.into_iter().next(), forms.pop()) { + c.messages.insert(text, tr); + } + } + Some(template) => c.templates.push(template), + None => {} + }, "@msgplural" => { let forms: Vec = tr.split('|').map(str::to_string).collect(); c.templates.extend(src.split('|').filter_map(|english| Template::parse(english, forms.clone(), true))); @@ -249,8 +257,8 @@ impl Catalog { self.templates.iter().find_map(|t| t.captures(s).map(|caps| (t.forms.as_slice(), caps))) } - /// The translation of a status or error message that has an exact `@msg` entry; templates are - /// not tried. + /// The translation of a status or error message that has an exact `@msg` entry, as written in + /// the catalog (`{{` and `}}` for literal braces); templates are not tried. pub fn exact_message(&self, s: &str) -> Option<&str> { self.messages.get(s).map(String::as_str) } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d58e5bd051..bde13aa090 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -207,6 +207,8 @@ Crop Image Oříznout obraz Image Tools Nástroje obrazu Remove Background… Odstranit pozadí… + Draw (Pencil) Kreslit (tužka) + Place Image Umístit obraz Remove Background Odstranit pozadí Background Pozadí Edge Color Barva okraje @@ -1599,6 +1601,7 @@ lighting Light Světlo Proportional Metrics Proporcionální metrika Insert Aki Left Vložit aki vlevo Insert Aki Right Vložit aki vpravo + Tsume Tsume Character Direction Směr znaků Digits Číslice Arabic Arabské @@ -6391,8 +6394,8 @@ axis Y Axis osa Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` exportní zásuvný modul deklaruje 1 až {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` mimo skupiny smí být nejvýše {MAX_UNGROUPED} parametrů: ostatním přiřaďte `group` @msg can't encode the input vstup nelze zakódovat -@msg expected {"dataBase64"} očekáváno {"dataBase64"} -@msg expected {"objects"} očekáváno {"objects"} +@msg expected {{"dataBase64": "…"}} očekáváno {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} očekáváno {{"objects": [...]}} @msg export `{name}`: {e} export `{name}`: {e} @msg extension {e} is listed twice přípona {e} je uvedena dvakrát @msg extension {e}: use 1-16 lower-case letters and digits, without the dot přípona {e}: použijte 1-16 malých písmen a číslic, bez tečky @@ -6456,11 +6459,11 @@ axis Y Axis osa Y @msg a painted live effect returns only `objects` malující živý efekt vrací jen `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` část malujícího živého efektu má jen `path`, `fillRule`, `fills` a `strokes` @msg a read-only object was left out objekt jen pro čtení byl vynechán -@msg a read-only object was left out: return it, as {"id"} alone or as it was given objekt jen pro čtení byl vynechán: vraťte ho jako samotné {"id"} nebo tak, jak byl předán +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given objekt jen pro čtení byl vynechán: vraťte ho jako samotné {{"id": …}} nebo tak, jak byl předán @msg a string řetězec @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` tah má jen `paint`, `width`, `join`, `cap` a `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} tah musí být {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} efekt musí být {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} tah musí být {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} efekt musí být {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` efekt na výplni vrací jen `fills` @msg an effect on a stroke returns only `strokes` efekt na tahu vrací jen `strokes` @msg an object must be a JSON object objekt musí být objekt JSON diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 5fad269299..f320896f62 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -194,6 +194,8 @@ Crop Image Beskær billede Image Tools Billedværktøjer Remove Background… Fjern baggrund… + Draw (Pencil) Tegn (blyant) + Place Image Placér billede Remove Background Fjern baggrund Background Baggrund Edge Color Kantfarve @@ -1507,6 +1509,7 @@ lighting Light Lys Proportional Metrics Proportionale metrikker Insert Aki Left Indsæt aki til venstre Insert Aki Right Indsæt aki til højre + Tsume Tsume Character Direction Tegnretning Digits Cifre Arabic Arabisk diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0143f55457..9ca688e1b5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -684,6 +684,8 @@ font-technology Variable Variabel Crop Image Bild beschneiden Image Tools Bildwerkzeuge Remove Background… Hintergrund entfernen… + Draw (Pencil) Zeichnen (Buntstift) + Place Image Bild platzieren Remove Background Hintergrund entfernen Background Hintergrund Edge Color Randfarbe @@ -2630,6 +2632,7 @@ mark-style Japanese Japanisch Proportional Metrics Proportionale Metrik Insert Aki Left Aki links einfügen Insert Aki Right Aki rechts einfügen + Tsume Tsume Character Direction Zeichenrichtung Digits Ziffern Arabic Arabisch @@ -5983,8 +5986,8 @@ axis Y Axis Y-Achse @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ein Export-Plug-in gibt 1 bis {MAX_EXTENSIONS} `extensions` an @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` höchstens {MAX_UNGROUPED} Parameter außerhalb von Gruppen: Geben Sie den anderen eine `group` @msg can't encode the input die Eingabe kann nicht codiert werden -@msg expected {"dataBase64"} erwartet: {"dataBase64"} -@msg expected {"objects"} erwartet: {"objects"} +@msg expected {{"dataBase64": "…"}} erwartet: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} erwartet: {{"objects": [...]}} @msg export `{name}`: {e} Modulexport `{name}`: {e} @msg extension {e} is listed twice die Dateiendung {e} ist doppelt aufgeführt @msg extension {e}: use 1-16 lower-case letters and digits, without the dot Dateiendung {e}: Verwenden Sie 1-16 Kleinbuchstaben und Ziffern, ohne Punkt @@ -6048,11 +6051,11 @@ axis Y Axis Y-Achse @msg a painted live effect returns only `objects` ein malender Live-Effekt gibt nur `objects` zurück @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ein Teil eines malenden Live-Effekts hat nur `path`, `fillRule`, `fills` und `strokes` @msg a read-only object was left out ein schreibgeschütztes Objekt wurde weggelassen -@msg a read-only object was left out: return it, as {"id"} alone or as it was given ein schreibgeschütztes Objekt wurde weggelassen: Geben Sie es zurück, als {"id"} allein oder so, wie es übergeben wurde +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given ein schreibgeschütztes Objekt wurde weggelassen: Geben Sie es zurück, als {{"id": …}} allein oder so, wie es übergeben wurde @msg a string eine Zeichenkette @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` eine Kontur hat nur `paint`, `width`, `join`, `cap` und `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} eine Kontur muss {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} sein -@msg an effect must be {"id", "params"?, "visible"?} ein Effekt muss {"id", "params"?, "visible"?} sein +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} eine Kontur muss {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} sein +@msg an effect must be {{"id", "params"?, "visible"?}} ein Effekt muss {{"id", "params"?, "visible"?}} sein @msg an effect on a fill returns only `fills` ein Effekt auf einer Fläche gibt nur `fills` zurück @msg an effect on a stroke returns only `strokes` ein Effekt auf einer Kontur gibt nur `strokes` zurück @msg an object must be a JSON object ein Objekt muss ein JSON-Objekt sein diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 172e91d0ef..776c992e7c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -665,6 +665,8 @@ font-technology Variable Μεταβλητές Crop Image Περικοπή εικόνας Image Tools Εργαλεία εικόνας Remove Background… Αφαίρεση φόντου… + Draw (Pencil) Σχεδίαση (μολύβι) + Place Image Τοποθέτηση εικόνας Remove Background Αφαίρεση φόντου Background Φόντο Edge Color Χρώμα άκρης @@ -3426,6 +3428,7 @@ theme Appearance Εμφάνιση Proportional Metrics Αναλογικές μετρικές Insert Aki Left Εισαγωγή aki αριστερά Insert Aki Right Εισαγωγή aki δεξιά + Tsume Tsume Arabic Αραβικά Hindi Χίντι Farsi Περσικά @@ -6343,8 +6346,8 @@ axis Y Axis Άξονας Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ένα πρόσθετο εξαγωγής δηλώνει 1 έως {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` το πολύ {MAX_UNGROUPED} παράμετροι εκτός ομάδων: δώστε στις υπόλοιπες ένα `group` @msg can't encode the input η είσοδος δεν μπορεί να κωδικοποιηθεί -@msg expected {"dataBase64"} αναμενόταν {"dataBase64"} -@msg expected {"objects"} αναμενόταν {"objects"} +@msg expected {{"dataBase64": "…"}} αναμενόταν {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} αναμενόταν {{"objects": [...]}} @msg export `{name}`: {e} εξαγωγή `{name}`: {e} @msg extension {e} is listed twice η επέκταση {e} αναφέρεται δύο φορές @msg extension {e}: use 1-16 lower-case letters and digits, without the dot επέκταση {e}: χρησιμοποιήστε 1-16 πεζά γράμματα και ψηφία, χωρίς την τελεία @@ -6408,11 +6411,11 @@ axis Y Axis Άξονας Y @msg a painted live effect returns only `objects` ένα ζωντανό εφέ βαφής επιστρέφει μόνο `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ένα κομμάτι ζωντανού εφέ βαφής έχει μόνο `path`, `fillRule`, `fills` και `strokes` @msg a read-only object was left out παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση -@msg a read-only object was left out: return it, as {"id"} alone or as it was given παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση: επιστρέψτε το ως {"id"} μόνο ή όπως δόθηκε +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση: επιστρέψτε το ως {{"id": …}} μόνο ή όπως δόθηκε @msg a string συμβολοσειρά @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` ένα περίγραμμα έχει μόνο `paint`, `width`, `join`, `cap` και `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} ένα περίγραμμα πρέπει να είναι {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} ένα εφέ πρέπει να είναι {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} ένα περίγραμμα πρέπει να είναι {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} ένα εφέ πρέπει να είναι {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` ένα εφέ σε γέμισμα επιστρέφει μόνο `fills` @msg an effect on a stroke returns only `strokes` ένα εφέ σε περίγραμμα επιστρέφει μόνο `strokes` @msg an object must be a JSON object ένα αντικείμενο πρέπει να είναι αντικείμενο JSON diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 865b2b532f..935d011e72 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -705,6 +705,8 @@ font-technology Variable Variables Crop Image Recortar imagen Image Tools Herramientas de imagen Remove Background… Quitar fondo… + Draw (Pencil) Dibujar (Lápiz) + Place Image Colocar imagen Remove Background Quitar fondo Background Fondo Edge Color Color del borde @@ -2664,6 +2666,7 @@ font-technology Variable Variables Proportional Metrics Métrica proporcional Insert Aki Left Insertar aki a la izquierda Insert Aki Right Insertar aki a la derecha + Tsume Tsume Character Direction Dirección de caracteres Digits Dígitos Arabic Árabes @@ -6334,8 +6337,8 @@ axis Y Axis Eje Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plugin de exportación declara de 1 a {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` como máximo {MAX_UNGROUPED} parámetros fuera de grupos: asigna un `group` a los demás @msg can't encode the input no se puede codificar la entrada -@msg expected {"dataBase64"} se esperaba {"dataBase64"} -@msg expected {"objects"} se esperaba {"objects"} +@msg expected {{"dataBase64": "…"}} se esperaba {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} se esperaba {{"objects": [...]}} @msg export `{name}`: {e} exportación del módulo `{name}`: {e} @msg extension {e} is listed twice la extensión {e} aparece dos veces @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensión {e}: usa 1-16 letras minúsculas y dígitos, sin el punto @@ -6399,11 +6402,11 @@ axis Y Axis Eje Y @msg a painted live effect returns only `objects` un efecto dinámico de pintura solo devuelve `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una pieza de un efecto dinámico de pintura solo tiene `path`, `fillRule`, `fills` y `strokes` @msg a read-only object was left out se omitió un objeto de solo lectura -@msg a read-only object was left out: return it, as {"id"} alone or as it was given se omitió un objeto de solo lectura: devuélvalo como {"id"} solo o tal como se recibió +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given se omitió un objeto de solo lectura: devuélvalo como {{"id": …}} solo o tal como se recibió @msg a string una cadena @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un trazo solo tiene `paint`, `width`, `join`, `cap` y `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un trazo debe ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} un efecto debe ser {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} un trazo debe ser {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} un efecto debe ser {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` un efecto en un relleno solo devuelve `fills` @msg an effect on a stroke returns only `strokes` un efecto en un trazo solo devuelve `strokes` @msg an object must be a JSON object un objeto debe ser un objeto JSON diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 88581f6ace..684589a9a1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -214,6 +214,8 @@ Crop Image Rajaa kuva Image Tools Kuvatyökalut Remove Background… Poista tausta… + Draw (Pencil) Piirrä (lyijykynä) + Place Image Sijoita kuva Remove Background Poista tausta Background Tausta Edge Color Reunan väri @@ -1574,6 +1576,7 @@ lighting Light Valo Proportional Metrics Suhteelliset metriikat Insert Aki Left Lisää aki vasemmalle Insert Aki Right Lisää aki oikealle + Tsume Tsume Character Direction Merkkien suunta Digits Numerot Arabic Arabia @@ -6421,8 +6424,8 @@ axis Y Axis Y-akseli @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` vientilaajennus ilmoittaa 1–{MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ryhmien ulkopuolella saa olla enintään {MAX_UNGROUPED} parametria: anna muille `group` @msg can't encode the input syötettä ei voi koodata -@msg expected {"dataBase64"} odotettiin {"dataBase64"} -@msg expected {"objects"} odotettiin {"objects"} +@msg expected {{"dataBase64": "…"}} odotettiin {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} odotettiin {{"objects": [...]}} @msg export `{name}`: {e} moduulin vienti `{name}`: {e} @msg extension {e} is listed twice tunniste {e} on luettelossa kahdesti @msg extension {e}: use 1-16 lower-case letters and digits, without the dot tunniste {e}: käytä 1-16 pientä kirjainta ja numeroa ilman pistettä @@ -6486,11 +6489,11 @@ axis Y Axis Y-akseli @msg a painted live effect returns only `objects` maalaava live-tehoste palauttaa vain kentän `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` maalaavan live-tehosteen osalla on vain `path`, `fillRule`, `fills` ja `strokes` @msg a read-only object was left out vain luku -objekti jätettiin pois -@msg a read-only object was left out: return it, as {"id"} alone or as it was given vain luku -objekti jätettiin pois: palauta se pelkkänä {"id"}-muotona tai sellaisena kuin se annettiin +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given vain luku -objekti jätettiin pois: palauta se pelkkänä {{"id": …}}-muotona tai sellaisena kuin se annettiin @msg a string merkkijono @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` viivalla on vain `paint`, `width`, `join`, `cap` ja `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} viivan on oltava muotoa {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} tehosteen on oltava muotoa {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} viivan on oltava muotoa {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} tehosteen on oltava muotoa {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` täytön tehoste palauttaa vain kentän `fills` @msg an effect on a stroke returns only `strokes` viivan tehoste palauttaa vain kentän `strokes` @msg an object must be a JSON object objektin on oltava JSON-objekti diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0063c984a1..9ceca79c90 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -707,6 +707,8 @@ font-technology Variable Variables Crop Image Recadrer l’image Image Tools Outils d’image Remove Background… Supprimer l’arrière-plan… + Draw (Pencil) Dessiner (Crayon) + Place Image Importer une image Remove Background Supprimer l’arrière-plan Background Arrière-plan Edge Color Couleur du bord @@ -2656,6 +2658,7 @@ font-technology Variable Variables Proportional Metrics Métrique proportionnelle Insert Aki Left Insérer un aki à gauche Insert Aki Right Insérer un aki à droite + Tsume Tsume Character Direction Direction des caractères Digits Chiffres Arabic Arabes @@ -6386,8 +6389,8 @@ axis Y Axis Axe Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un module externe d’exportation déclare de 1 à {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` {MAX_UNGROUPED} paramètres au plus hors des groupes : donnez un `group` aux autres @msg can't encode the input impossible d’encoder l’entrée -@msg expected {"dataBase64"} attendu : {"dataBase64"} -@msg expected {"objects"} attendu : {"objects"} +@msg expected {{"dataBase64": "…"}} attendu : {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} attendu : {{"objects": [...]}} @msg export `{name}`: {e} export du module `{name}` : {e} @msg extension {e} is listed twice l’extension {e} figure deux fois @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extension {e} : utilisez 1-16 lettres minuscules et chiffres, sans le point @@ -6451,11 +6454,11 @@ axis Y Axis Axe Y @msg a painted live effect returns only `objects` un effet dynamique de peinture renvoie seulement `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` une pièce d’un effet dynamique de peinture a seulement `path`, `fillRule`, `fills` et `strokes` @msg a read-only object was left out un objet en lecture seule a été omis -@msg a read-only object was left out: return it, as {"id"} alone or as it was given un objet en lecture seule a été omis : renvoyez-le, sous la forme {"id"} seul ou tel qu’il a été fourni +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given un objet en lecture seule a été omis : renvoyez-le, sous la forme {{"id": …}} seul ou tel qu’il a été fourni @msg a string une chaîne @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un contour a seulement `paint`, `width`, `join`, `cap` et `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un contour doit être {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} un effet doit être {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} un contour doit être {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} un effet doit être {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` un effet sur un fond renvoie seulement `fills` @msg an effect on a stroke returns only `strokes` un effet sur un contour renvoie seulement `strokes` @msg an object must be a JSON object un objet doit être un objet JSON @@ -7565,7 +7568,7 @@ breakText Glyphs Glyphes @msg `alignOn` must be a string `alignOn` doit être une chaîne @msg `align` is start, center or end `align` vaut start, center ou end @msg `align` must be a string `align` doit être une chaîne -@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` doit être une liste de paires [sous-tracé, point d’ancrage] +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` doit être une liste de paires [subpath, anchor] @msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` doit lister des indices de points d’ancrage (0, 1, 2…), pas {k} @msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` doit lister des indices de points d’ancrage (0, 1, 2…), pas {v} @msg `answer` for this {kind} dialog must be one of: {_1} `answer` pour cette boîte de dialogue {kind} doit valoir l’une de ces valeurs : {_1} @@ -7629,7 +7632,7 @@ breakText Glyphs Glyphes @msg `fidelity` must be from {lo} to {hi} pt `fidelity` doit être compris entre {lo} et {hi} pt @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` doit contenir de 1 à 1000 chemins de fichiers de police trouvés par la dernière recherche text.findFontFiles @msg `focal` and `radius` cannot be used together `focal` et `radius` ne peuvent pas être utilisés ensemble -@msg `focal` must be an array of [subpath, anchor] pairs `focal` doit être une liste de paires [sous-tracé, point d’ancrage] +@msg `focal` must be an array of [subpath, anchor] pairs `focal` doit être une liste de paires [subpath, anchor] @msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` désigne [{si}, {ai}], qui n’est pas un point d’ancrage du tracé @msg `folder` must be an absolute folder path `folder` doit être le chemin absolu d’un dossier @msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` doit être un objet de balises d’axe et de valeurs, comme {"wght"} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index a31993c84a..64873c5e2d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1140,6 +1140,8 @@ antiAlias Sharp शार्प Crop Image इमेज क्रॉप करें Image Tools इमेज टूल Remove Background… पृष्ठभूमि हटाएँ… + Draw (Pencil) ड्रा (पेंसिल) + Place Image इमेज प्लेस करें Remove Background पृष्ठभूमि हटाएँ Background पृष्ठभूमि Edge Color किनारे का रंग @@ -3696,6 +3698,7 @@ antiAlias Sharp शार्प Proportional Metrics आनुपातिक मीट्रिक Insert Aki Left बाईं ओर आकी जोड़ें Insert Aki Right दाईं ओर आकी जोड़ें + Tsume त्सुमे Proportional Oldstyle आनुपातिक ओल्डस्टाइल Protect Corners कॉर्नर को सुरक्षित रखें Proximity Guides निकटता गाइड @@ -5614,11 +5617,11 @@ antiAlias Sharp शार्प @msg a painted live effect returns only `objects` पेंट करने वाला लाइव इफ़ेक्ट केवल `objects` लौटाता है @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` पेंट करने वाले लाइव इफ़ेक्ट के हिस्से में केवल `path`, `fillRule`, `fills` और `strokes` होते हैं @msg a read-only object was left out एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया -@msg a read-only object was left out: return it, as {"id"} alone or as it was given एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया: इसे केवल {"id"} के रूप में या जैसा दिया गया था वैसा लौटाएँ +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया: इसे केवल {{"id": …}} के रूप में या जैसा दिया गया था वैसा लौटाएँ @msg a string एक स्ट्रिंग @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` स्ट्रोक में केवल `paint`, `width`, `join`, `cap` और `miterLimit` होते हैं -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} स्ट्रोक {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} होना चाहिए -@msg an effect must be {"id", "params"?, "visible"?} इफ़ेक्ट {"id", "params"?, "visible"?} होना चाहिए +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} स्ट्रोक {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} होना चाहिए +@msg an effect must be {{"id", "params"?, "visible"?}} इफ़ेक्ट {{"id", "params"?, "visible"?}} होना चाहिए @msg an effect on a fill returns only `fills` फ़िल पर इफ़ेक्ट केवल `fills` लौटाता है @msg an effect on a stroke returns only `strokes` स्ट्रोक पर इफ़ेक्ट केवल `strokes` लौटाता है @msg an object must be a JSON object ऑब्जेक्ट एक JSON ऑब्जेक्ट होना चाहिए @@ -6082,7 +6085,7 @@ antiAlias Sharp शार्प @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` में पिछली text.findFontFiles सर्च में मिली फ़ॉन्ट फ़ाइलों के 1 से 1000 पाथ होने चाहिए @msg `folder` must be an absolute folder path `folder` एक पूर्ण फ़ोल्डर पाथ होना चाहिए @msg `follow` must be "average", "a" or "b" `follow` को "average", "a" या "b" होना चाहिए। -@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` को अक्ष टैग और मानों का एक ऑब्जेक्ट होना चाहिए, जैसे कि {"wght"} +@msg `fontAxes` must be an object of axis tags and values, such as {{"wght": 650}} `fontAxes` को अक्ष टैग और मानों का एक ऑब्जेक्ट होना चाहिए, जैसे कि {{"wght": 650}} @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` को "emBox", "capHeight", "xHeight" या "icfBox" होना चाहिए @msg `format` must be a string `format` एक स्ट्रिंग होना चाहिए @msg `format` must be one of {_1} `format` को {_1} में से एक होना चाहिए @@ -6116,12 +6119,12 @@ antiAlias Sharp शार्प @msg `index` must be a point index `index` एक बिंदु सूचकांक होना चाहिए @msg `index` must be a whole number or null `index` होना चाहिए पूरी संख्या या null होना चाहिए @msg `indices` must be a non-empty array of item indices `indices` आइटम सूचकांकों की एक गैर-खाली सरणी होना चाहिए -@msg `items` is a list of {kind, item} `items` {kind, item} की सूची है +@msg `items` is a list of {{kind, item}} `items` {{kind, item}} की सूची है @msg `items` is empty `items` खाली है @msg `items` must be a list of {{id, anchors?}}, not {v} `items` को {{id, anchors?}} की सूची होना चाहिए, {v} नहीं -@msg `items` must be a list of {id, points} `items` को {id, points} की सूची होना चाहिए +@msg `items` must be a list of {{id, points}} `items` को {{id, points}} की सूची होना चाहिए @msg `items` must be an array of {{id, corners?}}, not {v} `items` को {{id, corners?}} की ऐरे होना चाहिए, {v} नहीं -@msg `items` must be an array of {id, anchors} `items` को {id, anchors} की ऐरे होना चाहिए +@msg `items` must be an array of {{id, anchors}} `items` को {{id, anchors}} की ऐरे होना चाहिए @msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` होना चाहिए "none", "short", "medium", "long" या "stylistic"। @msg `kind` is one of {_1} `kind` {_1} में से एक है @msg `kind` must be "line" or "paragraph", not {other} `kind` होना चाहिए "line" या "paragraph", नहीं {other} @@ -6406,7 +6409,7 @@ antiAlias Sharp शार्प @msg artboard {i} doesn't print आर्टबोर्ड {i} प्रिंट नहीं करता है @msg artboard is a 0-based index artboard 0 से शुरू होने वाला इंडेक्स है @msg artboard must be a 0-based index artboard 0 से शुरू होने वाला इंडेक्स होना चाहिए -@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout को {layout?, columns?, spacing?, rightToLeft?} होना चाहिए +@msg artboardLayout must be {{layout?, columns?, spacing?, rightToLeft?}} artboardLayout को {{layout?, columns?, spacing?, rightToLeft?}} होना चाहिए @msg artboardLayout.columns must be 1 or more artboardLayout.columns को 1 या अधिक होना चाहिए @msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout को gridByRow, gridByColumn, row या column होना चाहिए @msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft को true या false होना चाहिए @@ -6775,13 +6778,13 @@ antiAlias Sharp शार्प @msg DXF scale must be a positive number up to {_1}, not {scale} DXF स्केल {_1} तक की धनात्मक संख्या होनी चाहिए, न कि {scale} @msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf इकाई `{u}`: एक इकाई जैसे mm, cm, in या pt @msg dxf: {e} dxf: {e} -@msg each format is an object {format, scale?, suffix?} हर फ़ॉर्मैट एक ऑब्जेक्ट {format, scale?, suffix?} है +@msg each format is an object {{format, scale?, suffix?}} हर फ़ॉर्मैट एक ऑब्जेक्ट {{format, scale?, suffix?}} है @msg each item needs an id प्रत्येक आइटम को एक आईडी की आवश्यकता होती है @msg each match needs `kind` प्रत्येक मैच की जरूरत है `kind` @msg each match needs a `name` प्रत्येक मैच को `name` की आवश्यकता होती है -@msg each of `fonts` is {family, style?}; 1 to 1000 of them `fonts` का हर आइटम {family, style?} है; 1 से 1000 आइटम +@msg each of `fonts` is {{family, style?}}; 1 to 1000 of them `fonts` का हर आइटम {{family, style?}} है; 1 से 1000 आइटम @msg each of `items` is {{id, corners?}} with an object id, not {it} `items` का हर आइटम ऑब्जेक्ट आईडी वाला {{id, corners?}} है, {it} नहीं -@msg each of `items` is {kind, item} `items` का हर आइटम {kind, item} है +@msg each of `items` is {{kind, item}} `items` का हर आइटम {{kind, item}} है @msg each row needs `from` हर पंक्ति के लिए `from` चाहिए @msg each stop needs a position प्रत्येक स्टॉप को स्थिति की आवश्यकता होती है @msg each subpath needs an anchors array प्रत्येक उपपाथ को एक एंकर सरणी की आवश्यकता होती है @@ -6813,8 +6816,8 @@ antiAlias Sharp शार्प @msg Expand: no gradient fills विस्तार: कोई ग्रेडिएंट भरता नहीं @msg Expand: nothing to expand विस्तार: विस्तार करने के लिए कुछ नहीं @msg expanded SVG styles exceed the import size limit विस्तारित SVG स्टाइल्स आयात आकार सीमा से अधिक है -@msg expected {"dataBase64"} अपेक्षित: {"dataBase64"} -@msg expected {"objects"} अपेक्षित: {"objects"} +@msg expected {{"dataBase64": "…"}} अपेक्षित: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} अपेक्षित: {{"objects": [...]}} @msg export `{name}`: {e} निर्यात `{name}`: {e} @msg Export cancelled निर्यात रद्द @msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft स्क्रीन के लिए एक्सपोर्ट png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai या vectorcraft लिखता है @@ -6834,7 +6837,7 @@ antiAlias Sharp शार्प @msg features must be a list of tags features टैग की एक सूची होनी चाहिए @msg fidelity must be 0..100 fidelity 0..100 होना चाहिए @msg file version {0} is newer than this VectorCraft supports ({VERSION}) फ़ाइल संस्करण {0} इस वेक्टर क्रेफ्ट समर्थन ({VERSION}) की तुलना में नया है। -@msg files must be {name, dataBase64} files {name, dataBase64} होना चाहिए +@msg files must be {{name, dataBase64}} files {{name, dataBase64}} होना चाहिए @msg finish editing the pattern first पहले पैटर्न को संपादित करना @msg First measured segment no longer exists पहले मापा खंड अब मौजूद नहीं है @msg fit must be none|autoHeight|shrinkText|block, got {id} fit को none|autoHeight|shrinkText|block होना चाहिए, {id} मिला @@ -6860,7 +6863,7 @@ antiAlias Sharp शार्प @msg frequency must be between 1 and 300 lines per inch frequency प्रति इंच 1 से 300 लाइनों के बीच होनी चाहिए @msg from and to must be finite से और होना चाहिए करने के लिए @msg from must be [x0, y0, x1, y1] from [x0, y0, x1, y1] होना चाहिए -@msg give {key, value} or {values} {key, value} या {values} दें +@msg give {{key, value}} or {{values}} {{key, value}} या {{values}} दें @msg give `{a}` and `{b}` `{a}` और `{b}` दें @msg give `anchor`, the index of the anchor to slide खिसकाने वाले एंकर का इंडेक्स `anchor` दें @msg give `before` or `after`, not both `before` या `after` दें, दोनों नहीं @@ -6878,7 +6881,7 @@ antiAlias Sharp शार्प @msg give `id` or `guide`, not both `id` या `guide` दें, दोनों नहीं @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`, `removeWhiteOverprint` या `overprintBlack` दें @msg give `length` or `to` `length` या `to` देना -@msg give `matches`: [{kind, name, target}] `matches`: [{kind, name, target}] +@msg give `matches`: [{{kind, name, target}}] `matches`: [{{kind, name, target}}] @msg give `name` (or edit a pattern) `name` (या एक पैटर्न संपादित करें) @msg give `name` or `names` `name` या `names` दें @msg give `path` (a .wasm file) or `dataBase64` (the module) `path` (.wasm फ़ाइल) या `dataBase64` (मॉड्यूल) दें @@ -6935,7 +6938,7 @@ antiAlias Sharp शार्प @msg give the preset a `name` प्रीसेट देना `name` @msg give the preset a name प्रीसेट को एक नाम देना @msg give the preset a name (1–64 characters) प्रीसेट का नाम दें (1–64 अक्षर) -@msg give the settings to change, e.g. {"formats"}]} सेटिंग्स को बदलने के लिए, उदाहरण के लिए {"formats"}]} +@msg give the settings to change, e.g. {{"formats": [{{"format": "png", "scale": "2x"}}]}} सेटिंग्स को बदलने के लिए, उदाहरण के लिए {{"formats": [{{"format": "png", "scale": "2x"}}]}} @msg give the style a name स्टाइल को एक नाम दें @msg give two or more style `names` दो या अधिक स्टाइल `names` @msg give two or more swatches दो या अधिक स्वॉच दें @@ -7155,7 +7158,7 @@ antiAlias Sharp शार्प @msg missing `library` (see graphicStyle.libraries) लापता `library` (graphicStyle.libraries देखें) @msg missing `library` (see swatch.library.list) लापता `library` (swatch.library.list देखें) @msg missing `library` (see symbol.libraries) `library` अनुपस्थित है (symbol.libraries देखें) -@msg missing `map` (rows [{from, to}] or {current}) लापता `map` (rows [{from, to}] या {current}) +@msg missing `map` (rows [{{from, to}}] or {{current: new}}) लापता `map` (rows [{{from, to}}] या {{current: new}}) @msg missing `matrix` [a,b,c,d,e,f] लापता `matrix` [a,b,c,d,e,f] @msg missing `name` लापता `name` @msg missing `name` (see gradient.presets.list) लापता `name` (gradient.presets.list देखें) @@ -7525,7 +7528,7 @@ antiAlias Sharp शार्प @msg not an export presets file (format `{_1}`) यह एक्सपोर्ट प्रीसेट फ़ाइल नहीं है (फ़ॉर्मैट `{_1}`) @msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e} @msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें) -@msg not an RTF file: it doesn't start with {\\rtf RTF फ़ाइल नहीं: यह {\rtf के साथ शुरू नहीं होता है +@msg not an RTF file: it doesn't start with {{\\rtf RTF फ़ाइल नहीं: यह {{\rtf के साथ शुरू नहीं होता है @msg not area type (text in a frame) outside perspective पर्सपेक्टिव के बाहर का एरिया टाइप (फ़्रेम में टेक्स्ट) नहीं है @msg not editing an opacity mask ओपेसिटी मास्क संपादित नहीं किया जा रहा है @msg not found नहीं मिला @@ -7722,7 +7725,7 @@ antiAlias Sharp शार्प @msg printing isn't available here मुद्रण यहाँ उपलब्ध नहीं है @msg procedures nest too deeply प्रक्रियाओं घोंसला बहुत गहराई से @msg procedures nested too deeply प्रक्रियाओं ने बहुत गहराई से घोंसले -@msg quotes must be {double?, single?} quotes को {double?, single?} होना चाहिए +@msg quotes must be {{double?, single?}} quotes को {{double?, single?}} होना चाहिए @msg quotes.{k} must be an opening and a closing quote, e.g. "“”" उद्धरण.{k} एक उद्घाटन और एक समापन उद्धरण होना चाहिए, उदाहरण के लिए "“”" @msg range `{s}` is empty श्रेणी `{s}` खाली है @msg range `{s}`: `{part}` names no number सीमा `{s}`: `{part}` कोई संख्या नहीं बताता @@ -7963,14 +7966,14 @@ antiAlias Sharp शार्प @msg Switch x/y is for scatter graphs स्विच x/y स्कैटर ग्राफ़ के लिए है @msg t, dx and dy must be finite टी, डीएक्स और डीआई को परिमित होना चाहिए @msg Targa files are RGB: colorModel rgb Targa फ़ाइलें RGB हैं: colorModel rgb -@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text को {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} होना चाहिए +@msg text must be {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} text को {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} होना चाहिए @msg Text options: {e} टेक्स्ट ऑप्शन्स: {e} @msg text transform is collapsed or unrepresentable टेक्स्ट ट्रांसफ़ॉर्म सिमटा हुआ है या उसे दर्शाया नहीं जा सकता @msg Text Wrap: select a wrap object टेक्स्ट लपेटें: एक रैप ऑब्जेक्ट चुनें @msg text.{key} `{v}`: use "{_1}" or "{_2}" Text.{key} `{v}`: "{_1}" या "{_2}" का उपयोग करें। @msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces को 2 से 100 के बीच पूर्णांक होना चाहिए @msg textureImage: {e} textureImage: {e} -@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: किसी इमेज फ़ाइल का {path} या {name, dataBase64} दें, या effect.list द्वारा दिया गया टेक्सचर दें +@msg textureImage: give {{path}} or {{name, dataBase64}} of an image file, or a texture as effect.list gives it textureImage: किसी इमेज फ़ाइल का {{path}} या {{name, dataBase64}} दें, या effect.list द्वारा दिया गया टेक्सचर दें @msg that anchor can't slide: an end of an open path has nowhere to go कि एंकर स्लाइड नहीं कर सकते: एक खुले रास्ते का अंत कहीं नहीं जाना है @msg the active document changed while the question was open; nothing was deleted प्रश्न खुला रहते हुए सक्रिय डॉक्यूमेंट बदल गया; कुछ भी नहीं हटाया गया @msg that document is no longer open यह डॉक्यूमेंट अब खुला नहीं है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7b5d4eb4ae..e1ddc72b93 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -906,6 +906,8 @@ font-technology Variable Változó Crop Image Kép körülvágása Image Tools Képeszközök Remove Background… Háttér eltávolítása… + Draw (Pencil) Rajzolás (Ceruza) + Place Image Kép elhelyezése Remove Background Háttér eltávolítása Background Háttér Edge Color Szélszín @@ -3222,6 +3224,7 @@ mark-style Japanese Japán Proportional Metrics Arányos metrika Insert Aki Left Aki beszúrása balra Insert Aki Right Aki beszúrása jobbra + Tsume Tsume Proportional Lining Arányos modern Position: Pozíció: Default Position Alapértelmezett pozíció @@ -6374,8 +6377,8 @@ axis Y Axis Y tengely @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` az exportáló bővítmény 1–{MAX_EXTENSIONS} `extensions` értéket ad meg @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` csoporton kívül legfeljebb {MAX_UNGROUPED} paraméter lehet: a többinek adj meg `group` értéket @msg can't encode the input a bemenet nem kódolható -@msg expected {"dataBase64"} várt formátum: {"dataBase64"} -@msg expected {"objects"} várt formátum: {"objects"} +@msg expected {{"dataBase64": "…"}} várt formátum: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} várt formátum: {{"objects": [...]}} @msg export `{name}`: {e} `{name}` modulexport: {e} @msg extension {e} is listed twice a(z) {e} kiterjesztés kétszer szerepel @msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} kiterjesztés: 1-16 kisbetűt és számjegyet használj, pont nélkül @@ -6439,11 +6442,11 @@ axis Y Axis Y tengely @msg a painted live effect returns only `objects` a festő élő effektus csak `objects` értéket ad vissza @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` a festő élő effektus darabjának csak `path`, `fillRule`, `fills` és `strokes` kulcsa van @msg a read-only object was left out kimaradt egy írásvédett objektum -@msg a read-only object was left out: return it, as {"id"} alone or as it was given kimaradt egy írásvédett objektum: adja vissza önmagában {"id"} formában vagy úgy, ahogy megkapta +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given kimaradt egy írásvédett objektum: adja vissza önmagában {{"id": …}} formában vagy úgy, ahogy megkapta @msg a string karakterlánc @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` a körvonalnak csak `paint`, `width`, `join`, `cap` és `miterLimit` kulcsa van -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} a körvonal formája {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} legyen -@msg an effect must be {"id", "params"?, "visible"?} az effektus formája {"id", "params"?, "visible"?} legyen +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} a körvonal formája {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} legyen +@msg an effect must be {{"id", "params"?, "visible"?}} az effektus formája {{"id", "params"?, "visible"?}} legyen @msg an effect on a fill returns only `fills` a kitöltésen lévő effektus csak `fills` értéket ad vissza @msg an effect on a stroke returns only `strokes` a körvonalon lévő effektus csak `strokes` értéket ad vissza @msg an object must be a JSON object az objektum JSON-objektum legyen diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e27253376e..208877f673 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -194,6 +194,8 @@ Crop Image Pangkas gambar Image Tools Alat Gambar Remove Background… Hapus latar belakang… + Draw (Pencil) Menggambar (pensil) + Place Image Tempatkan gambar Remove Background Hapus latar belakang Background Latar belakang Edge Color Warna tepi @@ -1507,6 +1509,7 @@ lighting Light Cahaya Proportional Metrics Metrik proporsional Insert Aki Left Sisipkan aki kiri Insert Aki Right Sisipkan aki kanan + Tsume Tsume Character Direction Arah karakter Digits Digit Arabic Arab diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7d74485aac..1252743764 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -705,6 +705,8 @@ font-technology Variable Variabili Crop Image Ritaglia immagine Image Tools Strumenti immagine Remove Background… Rimuovi sfondo… + Draw (Pencil) Disegna (Matita) + Place Image Inserisci immagine Remove Background Rimuovi sfondo Background Sfondo Edge Color Colore del bordo @@ -2706,6 +2708,7 @@ mark-style Japanese Giapponese Proportional Metrics Metrica proporzionale Insert Aki Left Inserisci aki a sinistra Insert Aki Right Inserisci aki a destra + Tsume Tsume Character Direction Direzione caratteri Digits Cifre Arabic Arabe @@ -6017,8 +6020,8 @@ axis Y Axis Asse Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plug-in di esportazione dichiara da 1 a {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` al massimo {MAX_UNGROUPED} parametri fuori dai gruppi: assegna agli altri un `group` @msg can't encode the input impossibile codificare l’input -@msg expected {"dataBase64"} previsto {"dataBase64"} -@msg expected {"objects"} previsto {"objects"} +@msg expected {{"dataBase64": "…"}} previsto {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} previsto {{"objects": [...]}} @msg export `{name}`: {e} esportazione del modulo `{name}`: {e} @msg extension {e} is listed twice l’estensione {e} è elencata due volte @msg extension {e}: use 1-16 lower-case letters and digits, without the dot estensione {e}: usa 1-16 lettere minuscole e cifre, senza il punto @@ -6082,11 +6085,11 @@ axis Y Axis Asse Y @msg a painted live effect returns only `objects` un effetto dinamico di pittura restituisce solo `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` un pezzo di un effetto dinamico di pittura ha solo `path`, `fillRule`, `fills` e `strokes` @msg a read-only object was left out è stato omesso un oggetto di sola lettura -@msg a read-only object was left out: return it, as {"id"} alone or as it was given è stato omesso un oggetto di sola lettura: restituiscilo come {"id"} da solo o così come è stato fornito +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given è stato omesso un oggetto di sola lettura: restituiscilo come {{"id": …}} da solo o così come è stato fornito @msg a string una stringa @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` una traccia ha solo `paint`, `width`, `join`, `cap` e `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} una traccia deve essere {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} un effetto deve essere {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} una traccia deve essere {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} un effetto deve essere {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` un effetto su un riempimento restituisce solo `fills` @msg an effect on a stroke returns only `strokes` un effetto su una traccia restituisce solo `strokes` @msg an object must be a JSON object un oggetto deve essere un oggetto JSON diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 31526809eb..97a91c1ed2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -52,6 +52,7 @@ font-technology Variable バリアブル Proportional Metrics プロポーショナルメトリクス Insert Aki Left 前にアキを挿入 Insert Aki Right 後ろにアキを挿入 + Tsume 文字ツメ Character Direction 文字の方向 Digits 数字 Arabic アラビア数字 @@ -242,6 +243,8 @@ digits Malayalam マラヤーラム数字 Crop Image 画像の切り抜き Image Tools 画像ツール Remove Background… 背景を削除… + Draw (Pencil) 描画 (鉛筆) + Place Image 画像を配置 Remove Background 背景を削除 Background 背景 Edge Color 端の色 @@ -6083,8 +6086,8 @@ axis Y Axis Y 軸 @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 書き出しプラグインは `extensions` を 1~{MAX_EXTENSIONS} 個宣言します @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` グループ外のパラメーターは {MAX_UNGROUPED} 個までです。ほかのパラメーターには `group` を指定してください @msg can't encode the input 入力をエンコードできません -@msg expected {"dataBase64"} {"dataBase64"} が必要です -@msg expected {"objects"} {"objects"} が必要です +@msg expected {{"dataBase64": "…"}} {{"dataBase64": "…"}} が必要です +@msg expected {{"objects": [...]}} {{"objects": [...]}} が必要です @msg export `{name}`: {e} モジュールのエクスポート `{name}`: {e} @msg extension {e} is listed twice 拡張子 {e} が 2 回指定されています @msg extension {e}: use 1-16 lower-case letters and digits, without the dot 拡張子 {e}: ドットを付けずに小文字と数字で 1-16 文字にしてください @@ -6148,11 +6151,11 @@ axis Y Axis Y 軸 @msg a painted live effect returns only `objects` ペイント型のライブ効果が返すのは `objects` だけです @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ペイント型のライブ効果のピースに使えるのは `path`、`fillRule`、`fills`、`strokes` だけです @msg a read-only object was left out 読み取り専用のオブジェクトが省かれています -@msg a read-only object was left out: return it, as {"id"} alone or as it was given 読み取り専用のオブジェクトが省かれています: {"id"} だけの形か、渡されたとおりの形で返してください +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 読み取り専用のオブジェクトが省かれています: {{"id": …}} だけの形か、渡されたとおりの形で返してください @msg a string 文字列 @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 線に使えるのは `paint`、`width`、`join`、`cap`、`miterLimit` だけです -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 線は {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} の形にしてください -@msg an effect must be {"id", "params"?, "visible"?} 効果は {"id", "params"?, "visible"?} の形にしてください +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 線は {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} の形にしてください +@msg an effect must be {{"id", "params"?, "visible"?}} 効果は {{"id", "params"?, "visible"?}} の形にしてください @msg an effect on a fill returns only `fills` 塗りの効果が返すのは `fills` だけです @msg an effect on a stroke returns only `strokes` 線の効果が返すのは `strokes` だけです @msg an object must be a JSON object オブジェクトは JSON オブジェクトにしてください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a6429f06ee..9891fa66f2 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -166,6 +166,8 @@ Crop Image გამოსახულების ჩამოჭრა Image Tools გამოსახულების ხელსაწყოები Remove Background… ფონის მოშორება… + Draw (Pencil) ხატვა (ფანქარი) + Place Image გამოსახულების განთავსება Remove Background ფონის მოშორება Background ფონი Edge Color კიდის ფერი @@ -1444,6 +1446,7 @@ lighting Light სინათლე Proportional Metrics პროპორციული მეტრიკა Insert Aki Left აკის ჩასმა მარცხნივ Insert Aki Right აკის ჩასმა მარჯვნივ + Tsume ცუმე Character Direction სიმბოლოების მიმართულება Digits ციფრები Arabic არაბული @@ -6342,8 +6345,8 @@ axis Y Axis Y ღერძი @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ექსპორტის პლაგინი აცხადებს 1-დან {MAX_EXTENSIONS}-მდე `extensions`-ს @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ჯგუფების გარეთ მაქსიმუმ {MAX_UNGROUPED} პარამეტრია დასაშვები: დანარჩენებს მიეცით `group` @msg can't encode the input შეყვანის კოდირება ვერ ხერხდება -@msg expected {"dataBase64"} მოსალოდნელი იყო {"dataBase64"} -@msg expected {"objects"} მოსალოდნელი იყო {"objects"} +@msg expected {{"dataBase64": "…"}} მოსალოდნელი იყო {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} მოსალოდნელი იყო {{"objects": [...]}} @msg export `{name}`: {e} მოდულის ექსპორტი `{name}`: {e} @msg extension {e} is listed twice გაფართოება {e} ორჯერაა მითითებული @msg extension {e}: use 1-16 lower-case letters and digits, without the dot გაფართოება {e}: გამოიყენეთ 1-16 პატარა ასო და ციფრი, წერტილის გარეშე @@ -6407,11 +6410,11 @@ axis Y Axis Y ღერძი @msg a painted live effect returns only `objects` მხატვრული ცოცხალი ეფექტი აბრუნებს მხოლოდ `objects`-ს @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` მხატვრული ცოცხალი ეფექტის ნაწილს აქვს მხოლოდ `path`, `fillRule`, `fills` და `strokes` @msg a read-only object was left out გამოტოვებულია მხოლოდ წასაკითხი ობიექტი -@msg a read-only object was left out: return it, as {"id"} alone or as it was given გამოტოვებულია მხოლოდ წასაკითხი ობიექტი: დააბრუნეთ ის მხოლოდ {"id"}-ის სახით ან ისე, როგორც მიიღეთ +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given გამოტოვებულია მხოლოდ წასაკითხი ობიექტი: დააბრუნეთ ის მხოლოდ {{"id": …}}-ის სახით ან ისე, როგორც მიიღეთ @msg a string სტრიქონი @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` კონტურს აქვს მხოლოდ `paint`, `width`, `join`, `cap` და `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} კონტური უნდა იყოს {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} ეფექტი უნდა იყოს {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} კონტური უნდა იყოს {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} ეფექტი უნდა იყოს {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` შევსების ეფექტი აბრუნებს მხოლოდ `fills`-ს @msg an effect on a stroke returns only `strokes` კონტურის ეფექტი აბრუნებს მხოლოდ `strokes`-ს @msg an object must be a JSON object ობიექტი უნდა იყოს JSON ობიექტი @@ -7357,7 +7360,7 @@ font-script Japanese იაპონური Japanese Envelope #3 იაპონური კონვერტი #3 Japanese Envelope #4 იაპონური კონვერტი #4 @msg image '{key}' has no pixels and was left out გამოსახულებას '{key}' პიქსელები არ აქვს და გამოტოვდა -@msg not an RTF file: it doesn't start with {\\rtf RTF ფაილი არ არის: არ იწყება {\\rtf-ით +@msg not an RTF file: it doesn't start with {{\\rtf RTF ფაილი არ არის: არ იწყება {{\\rtf-ით # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools ტექსტის ხელსაწყოები Text Tweaker… ტექსტის მორგება… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7141602a3d..0cdcda85c8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -46,6 +46,7 @@ font-technology Variable 가변 Proportional Metrics 비례 메트릭 Insert Aki Left 앞 아키 삽입 Insert Aki Right 뒤 아키 삽입 + Tsume 츠메 Character Direction 문자 방향 Digits 숫자 Arabic 아라비아어 @@ -235,6 +236,8 @@ digits Malayalam 말라얄람어 Crop Image 이미지 자르기 Image Tools 이미지 도구 Remove Background… 배경 제거… + Draw (Pencil) 그리기(연필) + Place Image 이미지 가져오기 Remove Background 배경 제거 Background 배경 Edge Color 가장자리 색상 @@ -5978,8 +5981,8 @@ axis Y Axis Y축 @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 내보내기 플러그인은 `extensions`를 1~{MAX_EXTENSIONS}개 선언합니다 @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 그룹 밖의 매개변수는 최대 {MAX_UNGROUPED}개입니다. 나머지에는 `group`을 지정하세요 @msg can't encode the input 입력을 인코딩할 수 없습니다 -@msg expected {"dataBase64"} {"dataBase64"} 형식이어야 합니다 -@msg expected {"objects"} {"objects"} 형식이어야 합니다 +@msg expected {{"dataBase64": "…"}} {{"dataBase64": "…"}} 형식이어야 합니다 +@msg expected {{"objects": [...]}} {{"objects": [...]}} 형식이어야 합니다 @msg export `{name}`: {e} 모듈 내보내기 `{name}`: {e} @msg extension {e} is listed twice 확장자 {e}이(가) 두 번 나열되었습니다 @msg extension {e}: use 1-16 lower-case letters and digits, without the dot 확장자 {e}: 점 없이 소문자와 숫자 1-16자를 사용하세요 @@ -6043,11 +6046,11 @@ axis Y Axis Y축 @msg a painted live effect returns only `objects` 페인트형 라이브 효과는 `objects`만 반환합니다 @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 페인트형 라이브 효과의 조각에는 `path`, `fillRule`, `fills`, `strokes`만 있습니다 @msg a read-only object was left out 읽기 전용 개체가 빠졌습니다 -@msg a read-only object was left out: return it, as {"id"} alone or as it was given 읽기 전용 개체가 빠졌습니다: {"id"}만 있는 형태나 받은 그대로 반환하십시오 +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 읽기 전용 개체가 빠졌습니다: {{"id": …}}만 있는 형태나 받은 그대로 반환하십시오 @msg a string 문자열 @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 선에는 `paint`, `width`, `join`, `cap`, `miterLimit`만 있습니다 -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 선은 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 형태여야 합니다 -@msg an effect must be {"id", "params"?, "visible"?} 효과는 {"id", "params"?, "visible"?} 형태여야 합니다 +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 선은 {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 형태여야 합니다 +@msg an effect must be {{"id", "params"?, "visible"?}} 효과는 {{"id", "params"?, "visible"?}} 형태여야 합니다 @msg an effect on a fill returns only `fills` 칠에 적용된 효과는 `fills`만 반환합니다 @msg an effect on a stroke returns only `strokes` 선에 적용된 효과는 `strokes`만 반환합니다 @msg an object must be a JSON object 개체는 JSON 개체여야 합니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 476f707d22..50bd43201b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -290,8 +290,8 @@ @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` een exportplug-in declareert 1 tot {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` maximaal {MAX_UNGROUPED} parameters buiten groepen: geef de andere een `group` @msg can't encode the input de invoer kan niet worden gecodeerd -@msg expected {"dataBase64"} verwacht: {"dataBase64"} -@msg expected {"objects"} verwacht: {"objects"} +@msg expected {{"dataBase64": "…"}} verwacht: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} verwacht: {{"objects": [...]}} @msg export `{name}`: {e} export `{name}`: {e} @msg extension {e} is listed twice extensie {e} staat twee keer in de lijst @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensie {e}: gebruik 1-16 kleine letters en cijfers, zonder de punt @@ -355,11 +355,11 @@ @msg a painted live effect returns only `objects` een geschilderd live-effect geeft alleen `objects` terug @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` een stuk van een geschilderd live-effect heeft alleen `path`, `fillRule`, `fills` en `strokes` @msg a read-only object was left out een alleen-lezen object is weggelaten -@msg a read-only object was left out: return it, as {"id"} alone or as it was given een alleen-lezen object is weggelaten: geef het terug, als alleen {"id"} of zoals het is gegeven +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given een alleen-lezen object is weggelaten: geef het terug, als alleen {{"id": …}} of zoals het is gegeven @msg a string een string @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` een contour heeft alleen `paint`, `width`, `join`, `cap` en `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} een contour moet {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} zijn -@msg an effect must be {"id", "params"?, "visible"?} een effect moet {"id", "params"?, "visible"?} zijn +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} een contour moet {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} zijn +@msg an effect must be {{"id", "params"?, "visible"?}} een effect moet {{"id", "params"?, "visible"?}} zijn @msg an effect on a fill returns only `fills` een effect op een vulling geeft alleen `fills` terug @msg an effect on a stroke returns only `strokes` een effect op een contour geeft alleen `strokes` terug @msg an object must be a JSON object een object moet een JSON-object zijn @@ -712,6 +712,8 @@ Crop Image Afbeelding bijsnijden Image Tools Afbeeldingshulpmiddelen Remove Background… Achtergrond verwijderen… + Draw (Pencil) Tekenen (Potlood) + Place Image Afbeelding plaatsen Remove Background Achtergrond verwijderen Background Achtergrond Edge Color Randkleur @@ -2025,6 +2027,7 @@ lighting Light Licht Proportional Metrics Proportionele metingen Insert Aki Left Aki links invoegen Insert Aki Right Aki rechts invoegen + Tsume Tsume Character Direction Tekenrichting Digits Cijfers Arabic Arabisch diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index db2f6449c6..891e916724 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -171,6 +171,8 @@ Crop Image Kadruj obraz Image Tools Narzędzia obrazu Remove Background… Usuń tło… + Draw (Pencil) Rysuj (ołówek) + Place Image Umieść obraz Remove Background Usuń tło Background Tło Edge Color Kolor krawędzi @@ -1512,6 +1514,7 @@ lighting Light Światło Proportional Metrics Metryka proporcjonalna Insert Aki Left Wstaw aki z lewej Insert Aki Right Wstaw aki z prawej + Tsume Tsume Character Direction Kierunek znaków Digits Cyfry Arabic Arabskie @@ -6246,8 +6249,8 @@ axis Y Axis Oś Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` wtyczka eksportu deklaruje od 1 do {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` najwyżej {MAX_UNGROUPED} parametrów poza grupami: pozostałym nadaj `group` @msg can't encode the input nie można zakodować danych wejściowych -@msg expected {"dataBase64"} oczekiwano {"dataBase64"} -@msg expected {"objects"} oczekiwano {"objects"} +@msg expected {{"dataBase64": "…"}} oczekiwano {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} oczekiwano {{"objects": [...]}} @msg export `{name}`: {e} eksport modułu `{name}`: {e} @msg extension {e} is listed twice rozszerzenie {e} występuje dwa razy @msg extension {e}: use 1-16 lower-case letters and digits, without the dot rozszerzenie {e}: użyj 1-16 małych liter i cyfr, bez kropki @@ -6311,11 +6314,11 @@ axis Y Axis Oś Y @msg a painted live effect returns only `objects` malujący efekt dynamiczny zwraca tylko `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` element malującego efektu dynamicznego ma tylko `path`, `fillRule`, `fills` i `strokes` @msg a read-only object was left out pominięto obiekt tylko do odczytu -@msg a read-only object was left out: return it, as {"id"} alone or as it was given pominięto obiekt tylko do odczytu: zwróć go jako samo {"id"} lub w postaci, w jakiej został przekazany +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given pominięto obiekt tylko do odczytu: zwróć go jako samo {{"id": …}} lub w postaci, w jakiej został przekazany @msg a string ciągiem znaków @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` obrys ma tylko `paint`, `width`, `join`, `cap` i `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} obrys musi mieć postać {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} efekt musi mieć postać {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} obrys musi mieć postać {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} efekt musi mieć postać {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` efekt wypełnienia zwraca tylko `fills` @msg an effect on a stroke returns only `strokes` efekt obrysu zwraca tylko `strokes` @msg an object must be a JSON object obiekt musi być obiektem JSON diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2d5047337a..e45bf18d42 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -631,6 +631,8 @@ antiAlias Sharp Nítido Crop Image Recortar Imagem Image Tools Ferramentas de imagem Remove Background… Remover Fundo… + Draw (Pencil) Desenhar (Lápis) + Place Image Colocar Imagem Remove Background Remover Fundo Background Fundo Edge Color Cor da Borda @@ -2230,6 +2232,7 @@ antiAlias Sharp Nítido Proportional Metrics Métrica Proporcional Insert Aki Left Inserir Aki à Esquerda Insert Aki Right Inserir Aki à Direita + Tsume Tsume Character Direction Direção de Caracteres Digits Dígitos Arabic Arábicos @@ -6306,8 +6309,8 @@ axis Y Axis Eixo Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` um plug-in de exportação declara de 1 a {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` no máximo {MAX_UNGROUPED} parâmetros fora de grupos: dê um `group` aos outros @msg can't encode the input não é possível codificar a entrada -@msg expected {"dataBase64"} esperado {"dataBase64"} -@msg expected {"objects"} esperado {"objects"} +@msg expected {{"dataBase64": "…"}} esperado {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} esperado {{"objects": [...]}} @msg export `{name}`: {e} exportação do módulo `{name}`: {e} @msg extension {e} is listed twice a extensão {e} aparece duas vezes @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensão {e}: use 1-16 letras minúsculas e dígitos, sem o ponto @@ -6371,11 +6374,11 @@ axis Y Axis Eixo Y @msg a painted live effect returns only `objects` um efeito dinâmico de pintura retorna apenas `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` uma peça de um efeito dinâmico de pintura tem apenas `path`, `fillRule`, `fills` e `strokes` @msg a read-only object was left out um objeto somente leitura foi omitido -@msg a read-only object was left out: return it, as {"id"} alone or as it was given um objeto somente leitura foi omitido: retorne-o como {"id"} sozinho ou como foi recebido +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given um objeto somente leitura foi omitido: retorne-o como {{"id": …}} sozinho ou como foi recebido @msg a string uma string @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` um traçado tem apenas `paint`, `width`, `join`, `cap` e `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} um traçado deve ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} um efeito deve ser {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} um traçado deve ser {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} um efeito deve ser {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` um efeito em um preenchimento retorna apenas `fills` @msg an effect on a stroke returns only `strokes` um efeito em um traçado retorna apenas `strokes` @msg an object must be a JSON object um objeto deve ser um objeto JSON @@ -7274,7 +7277,7 @@ swatch Lime Lima Line Weight: Espessura da linha: Load Variable Library Carregar biblioteca de variáveis swatch Magenta Magenta -@msg not an RTF file: it doesn't start with {\\rtf não é um arquivo RTF: ele não começa com {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf não é um arquivo RTF: ele não começa com {{\\rtf swatch Ocean Oceano shape Open Aberta swatch Orange Laranja diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f4220735a4..b260a39c0f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -703,6 +703,8 @@ font-technology Variable Вариативные Crop Image Обрезать изображение Image Tools Инструменты изображения Remove Background… Удалить фон… + Draw (Pencil) Рисовать (Карандаш) + Place Image Поместить изображение Remove Background Удалить фон Background Фон Edge Color Цвет края @@ -2693,6 +2695,7 @@ mark-style Japanese Японский Proportional Metrics Пропорциональные метрики Insert Aki Left Вставить аки слева Insert Aki Right Вставить аки справа + Tsume Цумэ Character Direction Направление символов Digits Цифры Arabic Арабские @@ -6002,8 +6005,8 @@ axis Y Axis Ось Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` модуль экспорта объявляет от 1 до {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` вне групп может быть не более {MAX_UNGROUPED} параметров: задайте остальным `group` @msg can't encode the input не удаётся закодировать входные данные -@msg expected {"dataBase64"} ожидалось {"dataBase64"} -@msg expected {"objects"} ожидалось {"objects"} +@msg expected {{"dataBase64": "…"}} ожидалось {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} ожидалось {{"objects": [...]}} @msg export `{name}`: {e} экспорт модуля `{name}`: {e} @msg extension {e} is listed twice расширение {e} указано дважды @msg extension {e}: use 1-16 lower-case letters and digits, without the dot расширение {e}: используйте 1-16 строчных букв и цифр, без точки @@ -6067,11 +6070,11 @@ axis Y Axis Ось Y @msg a painted live effect returns only `objects` рисующий динамический эффект возвращает только `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` у части рисующего динамического эффекта есть только `path`, `fillRule`, `fills` и `strokes` @msg a read-only object was left out пропущен объект только для чтения -@msg a read-only object was left out: return it, as {"id"} alone or as it was given пропущен объект только для чтения: верните его как один {"id"} или в том виде, в каком он был передан +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given пропущен объект только для чтения: верните его как один {{"id": …}} или в том виде, в каком он был передан @msg a string строка @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` у обводки есть только `paint`, `width`, `join`, `cap` и `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} обводка должна иметь вид {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} эффект должен иметь вид {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} обводка должна иметь вид {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} эффект должен иметь вид {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` эффект на заливке возвращает только `fills` @msg an effect on a stroke returns only `strokes` эффект на обводке возвращает только `strokes` @msg an object must be a JSON object объект должен быть объектом JSON @@ -7379,7 +7382,7 @@ breakText Glyphs Глифы @id pathfinder.add Объединить @msg give corners of the path: anchors between two straight sides укажите углы контура: опорные точки между двумя прямыми сторонами @msg image '{key}' has no pixels and was left out изображение '{key}' не содержит пикселов и было пропущено -@msg not an RTF file: it doesn't start with {\\rtf это не файл RTF: он не начинается с {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf это не файл RTF: он не начинается с {{\\rtf @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG это файл PostScript (.ai, сохранённый в старом формате или без совместимости с PDF, либо EPS-файл), который пока нельзя открыть: сохраните его как PDF, .ai с совместимостью с PDF или SVG @msg this kind of brush has no options dialog yet у кистей этого типа пока нет диалога параметров digits Hindi Хинди diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 1649d0140e..3854b8d56f 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -693,6 +693,8 @@ font-technology Variable Variabla Crop Image Beskär bild Image Tools Bildverktyg Remove Background… Ta bort bakgrund… + Draw (Pencil) Rita (blyertspenna) + Place Image Placera bild Remove Background Ta bort bakgrund Background Bakgrund Edge Color Kantfärg @@ -2601,6 +2603,7 @@ font-technology Variable Variabla Proportional Metrics Proportionella mått Insert Aki Left Infoga aki till vänster Insert Aki Right Infoga aki till höger + Tsume Tsume Character Direction Teckenriktning Digits Siffror Arabic Arabiska @@ -6441,11 +6444,11 @@ axis Y Axis Y-axel @msg a painted live effect returns only `objects` en målande live-effekt returnerar bara `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` en del av en målande live-effekt har bara `path`, `fillRule`, `fills` och `strokes` @msg a read-only object was left out ett skrivskyddat objekt utelämnades -@msg a read-only object was left out: return it, as {"id"} alone or as it was given ett skrivskyddat objekt utelämnades: returnera det som enbart {"id"} eller som det gavs +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given ett skrivskyddat objekt utelämnades: returnera det som enbart {{"id": …}} eller som det gavs @msg a string en sträng @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` en kontur har bara `paint`, `width`, `join`, `cap` och `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} en kontur måste vara {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} en effekt måste vara {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} en kontur måste vara {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} en effekt måste vara {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` en effekt på en fyllning returnerar bara `fills` @msg an effect on a stroke returns only `strokes` en effekt på en kontur returnerar bara `strokes` @msg an object must be a JSON object ett objekt måste vara ett JSON-objekt @@ -6992,7 +6995,7 @@ anchor Points: Punkter: VectorCraft Updates Uppdateringar för VectorCraft Yonroku-ban Yonroku-ban @msg image '{key}' has no pixels and was left out bilden '{key}' har inga pixlar och utelämnades -@msg not an RTF file: it doesn't start with {\\rtf inte en RTF-fil: den börjar inte med {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf inte en RTF-fil: den börjar inte med {{\\rtf shape Open Öppen # Color Effects add-on: Select by Color Kind. @@ -7591,7 +7594,7 @@ digits Hindi Hindi @msg `expand` must be true or false `expand` måste vara true eller false @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` måste vara 1 till 1000 sökvägar till typsnittsfiler som den senaste text.findFontFiles-sökningen hittade @msg `folder` must be an absolute folder path `folder` måste vara en absolut mappsökväg -@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` måste vara ett objekt med axeltaggar och värden, till exempel {"wght"} +@msg `fontAxes` must be an object of axis tags and values, such as {{"wght": 650}} `fontAxes` måste vara ett objekt med axeltaggar och värden, till exempel {{"wght": 650}} @msg `format` must be a string `format` måste vara en sträng @msg `format` must be one of {_1} `format` måste vara något av {_1} @msg `fractions` is chop or scale `fractions` är chop eller scale @@ -7614,12 +7617,12 @@ digits Hindi Hindi @msg `index` must be a point index `index` måste vara ett punktindex @msg `index` must be a whole number or null `index` måste vara ett heltal eller null @msg `indices` must be a non-empty array of item indices `indices` måste vara en icke-tom array av objektindex -@msg `items` is a list of {kind, item} `items` är en lista med {kind, item} +@msg `items` is a list of {{kind, item}} `items` är en lista med {{kind, item}} @msg `items` is empty `items` är tom @msg `items` must be a list of {{id, anchors?}}, not {v} `items` måste vara en lista med {{id, anchors?}}, inte {v} -@msg `items` must be a list of {id, points} `items` måste vara en lista med {id, points} +@msg `items` must be a list of {{id, points}} `items` måste vara en lista med {{id, points}} @msg `items` must be an array of {{id, corners?}}, not {v} `items` måste vara en array av {{id, corners?}}, inte {v} -@msg `items` must be an array of {id, anchors} `items` måste vara en array av {id, anchors} +@msg `items` must be an array of {{id, anchors}} `items` måste vara en array av {{id, anchors}} @msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` måste vara "none", "short", "medium", "long" eller "stylistic" @msg `kind` is one of {_1} `kind` är något av {_1} @msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` måste vara "round", "invertedRound" eller "chamfer", inte {v} @@ -7785,7 +7788,7 @@ digits Hindi Hindi @msg artboard {i} doesn't print rityta {i} skrivs inte ut @msg artboard is a 0-based index artboard är ett 0-baserat index @msg artboard must be a 0-based index artboard måste vara ett 0-baserat index -@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout måste vara {layout?, columns?, spacing?, rightToLeft?} +@msg artboardLayout must be {{layout?, columns?, spacing?, rightToLeft?}} artboardLayout måste vara {{layout?, columns?, spacing?, rightToLeft?}} @msg artboardLayout.columns must be 1 or more artboardLayout.columns måste vara 1 eller mer @msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout måste vara gridByRow, gridByColumn, row eller column @msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft måste vara true eller false @@ -7933,13 +7936,13 @@ digits Hindi Hindi @msg DXF scale must be a positive number up to {_1}, not {scale} DXF-skalan måste vara ett positivt tal upp till {_1}, inte {scale} @msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf-enhet `{u}`: en enhet som mm, cm, in eller pt @msg dxf: {e} dxf: {e} -@msg each format is an object {format, scale?, suffix?} varje format är ett objekt {format, scale?, suffix?} +@msg each format is an object {{format, scale?, suffix?}} varje format är ett objekt {{format, scale?, suffix?}} @msg each item needs an id varje objekt behöver ett id @msg each match needs `kind` varje matchning behöver `kind` @msg each match needs a `name` varje matchning behöver ett `name` -@msg each of `fonts` is {family, style?}; 1 to 1000 of them varje post i `fonts` är {family, style?}; 1 till 1000 stycken +@msg each of `fonts` is {{family, style?}}; 1 to 1000 of them varje post i `fonts` är {{family, style?}}; 1 till 1000 stycken @msg each of `items` is {{id, corners?}} with an object id, not {it} varje post i `items` är {{id, corners?}} med ett objekt-id, inte {it} -@msg each of `items` is {kind, item} varje post i `items` är {kind, item} +@msg each of `items` is {{kind, item}} varje post i `items` är {{kind, item}} @msg each row needs `from` varje rad behöver `from` @msg each stop needs a position varje stopp behöver en position @msg each subpath needs an anchors array varje delbana behöver en anchors-array @@ -7957,8 +7960,8 @@ digits Hindi Hindi @msg EPS Save writes all artwork: use document.export for selected-only EPS När du sparar som EPS skrivs allt bildmaterial: använd document.export för EPS med bara det markerade @msg every numbered name for the result is taken alla numrerade namn för resultatet är upptagna @msg expanded SVG styles exceed the import size limit de expanderade SVG-stilarna överskrider storleksgränsen för import -@msg expected {"dataBase64"} förväntade {"dataBase64"} -@msg expected {"objects"} förväntade {"objects"} +@msg expected {{"dataBase64": "…"}} förväntade {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} förväntade {{"objects": [...]}} @msg export `{name}`: {e} export av `{name}`: {e} @msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft Exportera för skärmar skriver png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai eller vectorcraft @msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes filen med exportförinställningar är större än {MAX_PRESET_FILE_BYTES} byte @@ -7971,7 +7974,7 @@ digits Hindi Hindi @msg featureChanges must be a list of tags featureChanges måste vara en lista med taggar @msg features must be a list of tags features måste vara en lista med taggar @msg fidelity must be 0..100 fidelity måste vara 0..100 -@msg files must be {name, dataBase64} files måste vara {name, dataBase64} +@msg files must be {{name, dataBase64}} files måste vara {{name, dataBase64}} @msg fit must be none|autoHeight|shrinkText|block, got {id} fit måste vara none|autoHeight|shrinkText|block, fick {id} @msg Flare click coordinates must be finite numbers Klickkoordinaterna för Ljusreflex måste vara ändliga tal @msg flattener: {e} utplattning: {e} @@ -7989,7 +7992,7 @@ digits Hindi Hindi @msg frequency must be between 1 and 300 lines per inch frequency måste vara mellan 1 och 300 linjer per tum @msg from and to must be finite from och to måste vara ändliga @msg from must be [x0, y0, x1, y1] from måste vara [x0, y0, x1, y1] -@msg give {key, value} or {values} ange {key, value} eller {values} +@msg give {{key, value}} or {{values}} ange {{key, value}} eller {{values}} @msg give `before` or `after`, not both ange `before` eller `after`, inte båda @msg give `char` or `text` ange `char` eller `text` @msg give `colors`, or `color` and `rule` ange `colors`, eller `color` och `rule` @@ -8004,7 +8007,7 @@ digits Hindi Hindi @msg give `id` and `anchors`, or `items`, not both ange `id` och `anchors`, eller `items`, inte båda @msg give `id` or `guide`, not both ange `id` eller `guide`, inte båda @msg give `length` or `to` ange `length` eller `to` -@msg give `matches`: [{kind, name, target}] ange `matches`: [{kind, name, target}] +@msg give `matches`: [{{kind, name, target}}] ange `matches`: [{{kind, name, target}}] @msg give `name` (or edit a pattern) ange `name` (eller redigera ett mönster) @msg give `name` or `names` ange `name` eller `names` @msg give `path` (a .wasm file) or `dataBase64` (the module) ange `path` (en .wasm-fil) eller `dataBase64` (modulen) @@ -8040,7 +8043,7 @@ digits Hindi Hindi @msg give the preset a `name` ge förinställningen ett `name` @msg give the preset a name ge förinställningen ett namn @msg give the preset a name (1–64 characters) ge förinställningen ett namn (1–64 tecken) -@msg give the settings to change, e.g. {"formats"}]} ange inställningarna som ska ändras, t.ex. {"formats"}]} +@msg give the settings to change, e.g. {{"formats": [{{"format": "png", "scale": "2x"}}]}} ange inställningarna som ska ändras, t.ex. {{"formats": [{{"format": "png", "scale": "2x"}}]}} @msg give the style a name ge stilen ett namn @msg give two or more style `names` ange två eller fler stilnamn i `names` @msg give two or more swatches ange två eller fler färgrutor @@ -8145,7 +8148,7 @@ digits Hindi Hindi @msg missing `library` `library` saknas @msg missing `library` (see graphicStyle.libraries) `library` saknas (se graphicStyle.libraries) @msg missing `library` (see swatch.library.list) `library` saknas (se swatch.library.list) -@msg missing `map` (rows [{from, to}] or {current}) `map` saknas (rader [{from, to}] eller {current}) +@msg missing `map` (rows [{{from, to}}] or {{current: new}}) `map` saknas (rader [{{from, to}}] eller {{current: new}}) @msg missing `matrix` [a,b,c,d,e,f] `matrix` [a,b,c,d,e,f] saknas @msg missing `name` (see gradient.presets.list) `name` saknas (se gradient.presets.list) @msg missing `offset` (a distance in points) `offset` saknas (ett avstånd i punkter) @@ -8437,7 +8440,7 @@ digits Hindi Hindi @msg print settings: {e} utskriftsinställningar: {e} @msg procedures nest too deeply procedurerna är nästlade för djupt @msg procedures nested too deeply procedurerna nästlades för djupt -@msg quotes must be {double?, single?} quotes måste vara {double?, single?} +@msg quotes must be {{double?, single?}} quotes måste vara {{double?, single?}} @msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} måste vara ett inledande och ett avslutande citattecken, t.ex. "“”" @msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi måste vara 1–2400 (72 skärm, 150 medel, 300 hög) @msg rating must be a whole number from 0 to {MAX_RATING} rating måste vara ett heltal från 0 till {MAX_RATING} @@ -8549,13 +8552,13 @@ digits Hindi Hindi @msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG-filen är inte i UTF-8, UTF-16 eller ISO 8859-1 @msg swatchConflict must be "merge" or "add", not {v} swatchConflict måste vara "merge" eller "add", inte {v} @msg Switch x/y is for scatter graphs Växla x/y gäller spridningsdiagram -@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text måste vara {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} +@msg text must be {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} text måste vara {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} @msg Text options: {e} Textalternativ: {e} @msg text transform is collapsed or unrepresentable texttransformeringen är kollapsad eller kan inte återges @msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: använd "{_1}" eller "{_2}" @msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces måste vara ett heltal från 2 till 100 @msg textureImage: {e} textureImage: {e} -@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: ange {path} eller {name, dataBase64} för en bildfil, eller en textur så som effect.list anger den +@msg textureImage: give {{path}} or {{name, dataBase64}} of an image file, or a texture as effect.list gives it textureImage: ange {{path}} eller {{name, dataBase64}} för en bildfil, eller en textur så som effect.list anger den @msg that makes more than {MAX_PIECES} slices det ger fler än {MAX_PIECES} skivor @msg that object is not in the overlap det objektet ingår inte i överlappningen @msg the action set ids are not a list åtgärdsuppsättningarnas id:n är inte en lista diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 4f9f03e5f6..e51e321781 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2143,6 +2143,55 @@ fn message_keys_keep_doubled_braces() { assert_eq!(as_template(r"it doesn't start with {\rtf"), r"it doesn't start with {\rtf"); } +/// The braces of a message that is not a format string (a plain literal or a `&str` constant) are +/// literal, so its catalog key doubles them; a `format!` string or an `#[error(…)]` string keeps +/// its `{{`, `}}` and placeholders. +#[test] +fn plain_message_keys_double_their_braces() { + let code = concat!( + "const SHAPE: &str = \"a shape is {\\\"kind\\\": …}\";\n", + "fn f() -> Result<()> { Err(Error::Output(\"expected {\\\"dataBase64\\\": \\\"…\\\"}\".into())) }\n", + "fn g() -> Result<()> { Err(SHAPE.into()) }\n", + "fn h() -> Result<()> { Err(bad(C, \"not an RTF file: it doesn't start with {\\\\rtf\")) }\n", + "fn i() -> Result<()> { Err(bad(C, format!(\"`{key}` must be {{min?, max?}}\"))) }\n", + "#[error(\"the file is {0} bytes, not {{size}}\")]\n", + ); + let consts: std::collections::BTreeMap> = str_consts(code).into_iter().map(|(name, value)| (name, vec![value])).collect(); + // The engine's `error(` helper also occurs in each `#[error(` attribute. + let found: std::collections::BTreeSet = source_messages(code, &[("bad(", 1), ("error(", 0)], &consts).into_iter().collect(); + let want = [ + r#"expected {{"dataBase64": "…"}}"#, + r#"a shape is {{"kind": …}}"#, + r"not an RTF file: it doesn't start with {{\rtf", + "`{key}` must be {{min?, max?}}", + "the file is {0} bytes, not {{size}}", + ]; + assert_eq!(found, want.iter().map(|s| s.to_string()).collect::>(), "{found:#?}"); + assert_eq!(as_literal_key("a {b} c}"), "a {{b}} c}}"); +} + +/// A catalog row for a message with literal braces translates that message and no other: it has no +/// placeholder to take the rest of a message with the same start. +#[test] +fn literal_brace_rows_match_only_their_message() { + let c = Catalog::parse("@msg\texpected {{\"dataBase64\": \"…\"}}\terwartet: {{\"dataBase64\": \"…\"}}\n"); + let (form, caps) = c.message(r#"expected {"dataBase64": "…"}"#).expect("matches"); + assert!(caps.is_empty(), "{caps:?}"); + assert_eq!(fill_once(form, &caps), r#"erwartet: {"dataBase64": "…"}"#); + assert!(c.message("expected value at line 1 column 1").is_none()); + assert!(c.message(r#"expected {"dataBase64"}"#).is_none()); + assert_eq!(exact_translation(&c, r#"expected {"dataBase64": "…"}"#).as_deref(), Some(r#"erwartet: {"dataBase64": "…"}"#)); + // An exact message is found at any length; a template is matched only up to its limit. + let long = format!("{{{{{}}}}}", "x".repeat(3000)); + let c = Catalog::parse(&format!("@msg\t{long}\tlong {{{{}}}}\n@msg\tshort {{x}}\tshort {{x}} (de)\n")); + let shown = format!("{{{}}}", "x".repeat(3000)); + assert_eq!(c.message(&shown).map(|(f, _)| f), Some("long {{}}")); + assert_eq!(exact_translation(&c, &shown).as_deref(), Some("long {}")); + assert_eq!(c.message("short y").map(|(f, _)| f), Some("short {x} (de)")); + // In the real catalog, a serde reason after the plug-in output error stays English. + assert_eq!(message(de(), "plug-in output: expected value at line 1 column 1"), "Plug-in-Ausgabe: expected value at line 1 column 1"); +} + /// `plugin.run` of an importer, an exporter or a live effect names the command that runs it with /// the JSON that picks it, and every complete-message catalog translates that error with the /// plug-in's id and the JSON kept. @@ -4207,7 +4256,8 @@ const LATER_MESSAGE_VARIANTS: &[(&str, usize)] = &[("Disabled", 1)]; const NOT_MESSAGES: &[&str] = &["quit", "cancelled", "vc_manifest", "[{i}]{place}", ".{k}{place}"]; /// Every status and error message literal in [`MESSAGE_CRATES`] (test modules aside), as catalog -/// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`). +/// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`), and +/// the braces of a message that is not a format string are doubled. pub fn message_literals() -> std::collections::BTreeSet { message_literals_in(MESSAGE_CRATES) } @@ -4330,16 +4380,19 @@ fn named_const<'a>(rest: &str, consts: &'a std::collections::BTreeMap>) -> Vec { let code = &without_comments(code); - let after_markers = MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| &code[at + marker.len()..])); + let after_markers = + MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| (&code[at + marker.len()..], *marker == "#[error("))); let in_variants = ["Error::", "Fail::"].into_iter().flat_map(|enum_end| code.match_indices(enum_end)).filter_map(|(at, m)| { let rest = &code[at + m.len()..]; let (variant, args) = rest.split_at(rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len())); let named = variant.starts_with(|c: char| c.is_ascii_uppercase()) && !NAME_VARIANTS.contains(&variant); let before = LATER_MESSAGE_VARIANTS.iter().find(|(v, _)| *v == variant).map_or(0, |&(_, n)| n); - if named { skip_args(args.strip_prefix('(')?, before) } else { None } + if named { skip_args(args.strip_prefix('(')?, before).map(|rest| (rest, false)) } else { None } }); let in_map_err = code.match_indices(".map_err(").flat_map(|(at, m)| { let rest = code[at + m.len()..].trim_start(); @@ -4358,13 +4411,20 @@ fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collecti let in_helpers = helpers.iter().flat_map(|&(call, before)| { code.match_indices(call) .filter(|&(at, _)| call.starts_with('.') || !code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.')) - .filter_map(move |(at, _)| skip_args(&code[at + call.len()..], before)) + // `format!(` given as a helper, and an `error(` helper that is an `#[error(…)]` attribute, take a format string. + .filter_map(move |(at, _)| { + skip_args(&code[at + call.len()..], before).map(|rest| (rest, call == "format!(" || code[..at].ends_with("#["))) + }) }); let mut out = vec![]; - for rest in after_markers.chain(in_variants).chain(in_map_err).chain(in_handlers).chain(in_helpers) { + let in_map_err = in_map_err.map(|rest| (rest, false)); + let in_handlers = in_handlers.map(|rest| (rest, false)); + for (rest, takes_format) in after_markers.chain(in_variants).chain(in_map_err).chain(in_handlers).chain(in_helpers) { let rest = rest.trim_start(); let rest = rest.strip_prefix('&').map_or(rest, str::trim_start); - let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start); + let formatted = rest.strip_prefix("format!(").map(str::trim_start); + let format = takes_format || formatted.is_some(); + let rest = formatted.unwrap_or(rest); let lits = match rest.strip_prefix('"') { Some(quoted) => string_literal(quoted).into_iter().collect(), None => named_const(rest, consts).to_vec(), @@ -4373,7 +4433,7 @@ fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collecti let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase)); let key_name = lit.len() > 1 && lit.starts_with('`') && lit.ends_with('`') && !lit.contains(' '); if has_word && !key_name && !NOT_MESSAGES.contains(&lit.as_str()) { - out.push(as_template(&lit)); + out.push(if format { as_template(&lit) } else { as_literal_key(&lit) }); } } } @@ -4568,6 +4628,12 @@ fn as_template(s: &str) -> String { out } +/// A message that is not a format string as a catalog key: each of its braces is literal, so it is +/// written `{{` or `}}`. +fn as_literal_key(s: &str) -> String { + s.replace('{', "{{").replace('}', "}}") +} + /// Render-site labels looked up in a context fall back to the plain row and then to English, and /// their templates fill in English as in any language. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0fcf935e7e..17575e2732 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -685,6 +685,8 @@ font-technology Variable Değişken Crop Image Resmi Kırp Image Tools Resim Araçları Remove Background… Arka Planı Kaldır… + Draw (Pencil) Çiz (Kurşun Kalem) + Place Image Resim Yerleştir Remove Background Arka Planı Kaldır Background Arka Plan Edge Color Kenar Rengi @@ -2448,6 +2450,7 @@ shape Open Açık Proportional Metrics Orantılı Metrikler Insert Aki Left Sola Aki Ekle Insert Aki Right Sağa Aki Ekle + Tsume Tsume Digits Rakamlar Arabic Arapça Hindi Hintçe @@ -4517,7 +4520,7 @@ axis Both Her İkisi @msg not an HP-GL file HP-GL dosyası değil @msg not a PICT file PICT dosyası değil @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 1 ile 255 arasında #rrggbb renginden oluşan bir dizi olmalıdır -@msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor +@msg not an RTF file: it doesn't start with {{\\rtf bir RTF dosyası değil: {{\\rtf ile başlamıyor @msg not found bulunamadı @msg not in pattern editing mode desen düzenleme modunda değil @msg finish editing the pattern first önce deseni düzenlemeyi bitir @@ -6347,8 +6350,8 @@ axis Y Axis Y ekseni @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` dışa aktarma eklentisi 1 ile {MAX_EXTENSIONS} arasında `extensions` bildirir @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` gruplar dışında en fazla {MAX_UNGROUPED} parametre olabilir: diğerlerine bir `group` verin @msg can't encode the input girdi kodlanamıyor -@msg expected {"dataBase64"} beklenen: {"dataBase64"} -@msg expected {"objects"} beklenen: {"objects"} +@msg expected {{"dataBase64": "…"}} beklenen: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} beklenen: {{"objects": [...]}} @msg export `{name}`: {e} modül dışa aktarımı `{name}`: {e} @msg extension {e} is listed twice {e} uzantısı iki kez listelenmiş @msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} uzantısı: nokta olmadan 1-16 küçük harf ve rakam kullanın @@ -6412,11 +6415,11 @@ axis Y Axis Y ekseni @msg a painted live effect returns only `objects` boyama türü canlı efekt yalnızca `objects` döndürür @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` boyama türü canlı efektin bir parçasında yalnızca `path`, `fillRule`, `fills` ve `strokes` bulunur @msg a read-only object was left out salt okunur bir nesne atlandı -@msg a read-only object was left out: return it, as {"id"} alone or as it was given salt okunur bir nesne atlandı: onu yalnızca {"id"} olarak veya verildiği gibi döndürün +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given salt okunur bir nesne atlandı: onu yalnızca {{"id": …}} olarak veya verildiği gibi döndürün @msg a string bir dize @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` bir konturda yalnızca `paint`, `width`, `join`, `cap` ve `miterLimit` bulunur -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} bir kontur {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} biçiminde olmalıdır -@msg an effect must be {"id", "params"?, "visible"?} bir efekt {"id", "params"?, "visible"?} biçiminde olmalıdır +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} bir kontur {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} biçiminde olmalıdır +@msg an effect must be {{"id", "params"?, "visible"?}} bir efekt {{"id", "params"?, "visible"?}} biçiminde olmalıdır @msg an effect on a fill returns only `fills` dolgudaki bir efekt yalnızca `fills` döndürür @msg an effect on a stroke returns only `strokes` konturdaki bir efekt yalnızca `strokes` döndürür @msg an object must be a JSON object bir nesne bir JSON nesnesi olmalıdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 168bd4ac84..a92fb0329b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -699,6 +699,8 @@ font-technology Variable Варіативні Crop Image Обрізати зображення Image Tools Інструменти зображення Remove Background… Видалити фон… + Draw (Pencil) Малювати (Олівець) + Place Image Помістити зображення Remove Background Видалити фон Background Фон Edge Color Колір краю @@ -2698,6 +2700,7 @@ mark-style Japanese Японський Proportional Metrics Пропорційні метрики Insert Aki Left Вставити акі ліворуч Insert Aki Right Вставити акі праворуч + Tsume Цуме Character Direction Напрямок символів Digits Цифри Arabic Арабські @@ -5997,8 +6000,8 @@ axis Y Axis Вісь Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` модуль експорту оголошує від 1 до {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` поза групами може бути не більше {MAX_UNGROUPED} параметрів: задайте решті `group` @msg can't encode the input не вдається закодувати вхідні дані -@msg expected {"dataBase64"} очікувалося {"dataBase64"} -@msg expected {"objects"} очікувалося {"objects"} +@msg expected {{"dataBase64": "…"}} очікувалося {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} очікувалося {{"objects": [...]}} @msg export `{name}`: {e} експорт модуля `{name}`: {e} @msg extension {e} is listed twice розширення {e} вказано двічі @msg extension {e}: use 1-16 lower-case letters and digits, without the dot розширення {e}: використовуйте 1-16 малих літер і цифр, без крапки @@ -6062,11 +6065,11 @@ axis Y Axis Вісь Y @msg a painted live effect returns only `objects` малювальний динамічний ефект повертає лише `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` частина малювального динамічного ефекту має лише `path`, `fillRule`, `fills` і `strokes` @msg a read-only object was left out пропущено об’єкт лише для читання -@msg a read-only object was left out: return it, as {"id"} alone or as it was given пропущено об’єкт лише для читання: поверніть його як один {"id"} або в тому вигляді, у якому його передано +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given пропущено об’єкт лише для читання: поверніть його як один {{"id": …}} або в тому вигляді, у якому його передано @msg a string рядок @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` обведення має лише `paint`, `width`, `join`, `cap` і `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} обведення має мати вигляд {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} ефект має мати вигляд {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} обведення має мати вигляд {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} ефект має мати вигляд {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` ефект на заливці повертає лише `fills` @msg an effect on a stroke returns only `strokes` ефект на обведенні повертає лише `strokes` @msg an object must be a JSON object об’єкт має бути об’єктом JSON diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bfc6b7876a..2f53625bf8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -806,6 +806,8 @@ font-technology Variable Biến thiên Crop Image Cắt xén ảnh Image Tools Công cụ hình ảnh Remove Background… Xóa nền… + Draw (Pencil) Vẽ (Bút chì) + Place Image Đặt hình ảnh Remove Background Xóa nền Background Nền Edge Color Màu cạnh @@ -3011,6 +3013,7 @@ mark-style Japanese Kiểu Nhật Proportional Metrics Số liệu theo tỷ lệ Insert Aki Left Chèn aki bên trái Insert Aki Right Chèn aki bên phải + Tsume Tsume Proportional Lining Số tỷ lệ đứng Position: Vị trí: Default Position Vị trí mặc định @@ -5998,8 +6001,8 @@ axis Y Axis Trục Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` plug-in xuất khai báo từ 1 đến {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` tối đa {MAX_UNGROUPED} tham số nằm ngoài nhóm: hãy đặt `group` cho các tham số còn lại @msg can't encode the input không thể mã hóa dữ liệu đầu vào -@msg expected {"dataBase64"} cần có {"dataBase64"} -@msg expected {"objects"} cần có {"objects"} +@msg expected {{"dataBase64": "…"}} cần có {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} cần có {{"objects": [...]}} @msg export `{name}`: {e} export `{name}` của mô-đun: {e} @msg extension {e} is listed twice phần mở rộng {e} được liệt kê hai lần @msg extension {e}: use 1-16 lower-case letters and digits, without the dot phần mở rộng {e}: dùng 1-16 chữ thường và chữ số, không có dấu chấm @@ -6063,11 +6066,11 @@ axis Y Axis Trục Y @msg a painted live effect returns only `objects` hiệu ứng động kiểu tô chỉ trả về `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` một phần của hiệu ứng động kiểu tô chỉ có `path`, `fillRule`, `fills` và `strokes` @msg a read-only object was left out một đối tượng chỉ đọc đã bị bỏ sót -@msg a read-only object was left out: return it, as {"id"} alone or as it was given một đối tượng chỉ đọc đã bị bỏ sót: hãy trả lại nó dưới dạng chỉ {"id"} hoặc như khi được nhận +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given một đối tượng chỉ đọc đã bị bỏ sót: hãy trả lại nó dưới dạng chỉ {{"id": …}} hoặc như khi được nhận @msg a string một chuỗi @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` nét chỉ có `paint`, `width`, `join`, `cap` và `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} nét phải có dạng {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} hiệu ứng phải có dạng {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} nét phải có dạng {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} hiệu ứng phải có dạng {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` hiệu ứng trên phần tô chỉ trả về `fills` @msg an effect on a stroke returns only `strokes` hiệu ứng trên nét chỉ trả về `strokes` @msg an object must be a JSON object đối tượng phải là một đối tượng JSON diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0d760c21ed..8a7b09aa41 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -650,6 +650,8 @@ antiAlias Sharp 锐利 Crop Image 裁剪图像 Image Tools 图像工具 Remove Background… 移除背景… + Draw (Pencil) 绘制 (铅笔) + Place Image 置入图像 Remove Background 移除背景 Background 背景 Edge Color 边缘颜色 @@ -2561,6 +2563,7 @@ font-script Japanese 汉字 Proportional Metrics 比例度量 Insert Aki Left 在前方插入空白 Insert Aki Right 在后方插入空白 + Tsume 比例间距 Character Direction 字符方向 Digits 数字 Arabic 阿拉伯数字 @@ -6230,8 +6233,8 @@ axis Y Axis Y 轴 @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 导出增效工具声明 1 到 {MAX_EXTENSIONS} 个 `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 组外最多 {MAX_UNGROUPED} 个参数:请为其余参数指定 `group` @msg can't encode the input 无法编码输入 -@msg expected {"dataBase64"} 应为 {"dataBase64"} -@msg expected {"objects"} 应为 {"objects"} +@msg expected {{"dataBase64": "…"}} 应为 {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} 应为 {{"objects": [...]}} @msg export `{name}`: {e} 模块导出 `{name}`:{e} @msg extension {e} is listed twice 扩展名 {e} 列出了两次 @msg extension {e}: use 1-16 lower-case letters and digits, without the dot 扩展名 {e}:请使用 1-16 个小写字母和数字,不带点 @@ -6295,11 +6298,11 @@ axis Y Axis Y 轴 @msg a painted live effect returns only `objects` 绘制型实时效果只返回 `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 绘制型实时效果的片段只有 `path`、`fillRule`、`fills` 和 `strokes` @msg a read-only object was left out 漏掉了一个只读对象 -@msg a read-only object was left out: return it, as {"id"} alone or as it was given 漏掉了一个只读对象:请以单独的 {"id"} 或原样返回它 +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 漏掉了一个只读对象:请以单独的 {{"id": …}} 或原样返回它 @msg a string 字符串 @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 描边只有 `paint`、`width`、`join`、`cap` 和 `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 描边必须是 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 形式 -@msg an effect must be {"id", "params"?, "visible"?} 效果必须是 {"id", "params"?, "visible"?} 形式 +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 描边必须是 {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 形式 +@msg an effect must be {{"id", "params"?, "visible"?}} 效果必须是 {{"id", "params"?, "visible"?}} 形式 @msg an effect on a fill returns only `fills` 填色上的效果只返回 `fills` @msg an effect on a stroke returns only `strokes` 描边上的效果只返回 `strokes` @msg an object must be a JSON object 对象必须是 JSON 对象 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 899520f58e..4deead50de 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -7,6 +7,7 @@ # the translation is a single string (see `plural` in i18n.rs) # anything else a disambiguating context passed to `tr_ctx` # escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# In @msg rows a literal brace is written {{ or }}, as in a Rust format string. # lines starting with # and blank lines are ignored. # # Clean-room: every entry was written from the plain meaning of the English label using the @@ -667,6 +668,8 @@ font-technology Variable 可變 Crop Image 裁切影像 Image Tools 影像工具 Remove Background… 移除背景… + Draw (Pencil) 繪製 (鉛筆) + Place Image 置入影像 Remove Background 移除背景 Background 背景 Edge Color 邊緣顏色 @@ -3777,6 +3780,7 @@ font-script Japanese 漢字 Proportional Metrics 比例度量 Insert Aki Left 在前方插入空白 Insert Aki Right 在後方插入空白 + Tsume 比例間距 Character Direction 字元方向 Digits 數字 Arabic 阿拉伯數字 @@ -6036,8 +6040,8 @@ axis Y Axis Y 軸 @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 匯出增效模組宣告 1 到 {MAX_EXTENSIONS} 個 `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 群組外最多 {MAX_UNGROUPED} 個參數:請為其餘參數指定 `group` @msg can't encode the input 無法編碼輸入 -@msg expected {"dataBase64"} 應為 {"dataBase64"} -@msg expected {"objects"} 應為 {"objects"} +@msg expected {{"dataBase64": "…"}} 應為 {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} 應為 {{"objects": [...]}} @msg export `{name}`: {e} 模組匯出 `{name}`:{e} @msg extension {e} is listed twice 副檔名 {e} 列出了兩次 @msg extension {e}: use 1-16 lower-case letters and digits, without the dot 副檔名 {e}:請使用 1-16 個小寫字母和數字,不含點 @@ -6101,11 +6105,11 @@ axis Y Axis Y 軸 @msg a painted live effect returns only `objects` 繪製型即時效果只傳回 `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 繪製型即時效果的片段只有 `path`、`fillRule`、`fills` 和 `strokes` @msg a read-only object was left out 漏掉了一個唯讀物件 -@msg a read-only object was left out: return it, as {"id"} alone or as it was given 漏掉了一個唯讀物件:請以單獨的 {"id"} 或原樣傳回它 +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 漏掉了一個唯讀物件:請以單獨的 {{"id": …}} 或原樣傳回它 @msg a string 字串 @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 筆畫只有 `paint`、`width`、`join`、`cap` 和 `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 筆畫必須是 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 形式 -@msg an effect must be {"id", "params"?, "visible"?} 效果必須是 {"id", "params"?, "visible"?} 形式 +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 筆畫必須是 {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 形式 +@msg an effect must be {{"id", "params"?, "visible"?}} 效果必須是 {{"id", "params"?, "visible"?}} 形式 @msg an effect on a fill returns only `fills` 填色上的效果只傳回 `fills` @msg an effect on a stroke returns only `strokes` 筆畫上的效果只傳回 `strokes` @msg an object must be a JSON object 物件必須是 JSON 物件 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index eec2a66831..ee083d7d44 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -23,7 +23,8 @@ mod tests_read_only_save; pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, export_pdf, hide_exports, reveal, save_command_output}; pub(crate) use export::{ - export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, + EXPORT_SELECTION, export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, + selection_path, }; pub(crate) use open::report_installed; pub(crate) use open::{EXPORT_PRESETS, import_presets, open_notes, open_with_on, opened, opened_after_notice, show_opened}; @@ -436,7 +437,9 @@ mod tests { app.session.execute("file.new", &json!({"width": 60, "height": 40})).unwrap(); app.session.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); app.services.pick_save = Some(Box::new(|_: &FilePick| Some("/tmp/sel.svg".into()))); - assert_eq!(app.run("document.exportSelection", json!({})).unwrap()["path"], "/tmp/sel.svg"); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("svgOptions"), "the picked format's options follow"); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["path"], "/tmp/sel.svg"); app.run("document.exportSelection", json!({"path": "/tmp/sel.png"})).unwrap(); let w = written.borrow(); assert!(String::from_utf8_lossy(&w[0].1).contains(" Result<(bool, Value), String> { + let mut p = params.clone(); + let Some(o) = p.as_object_mut() else { return Ok((false, p)) }; + match o.remove(EXPORT_SELECTION) { + None | Some(Value::Null | Value::Bool(false)) => return Ok((false, p)), + Some(Value::Bool(true)) => {} + Some(v) => return Err(format!("{EXPORT_SELECTION} must be true or false, not {v}")), + } + let strip = |o: &mut serde_json::Map| { + for k in fileio::ARTBOARD_PARAMS.iter().chain(&["useArtboards", "selectedOnly"]) { + o.remove(*k); + } + }; + strip(o); + if let Some(svg) = o.get_mut("svg").and_then(Value::as_object_mut) { + strip(svg); + } + Ok((true, p)) +} + /// File → Cancel Export (the progress dialog's Cancel): every export running in the background /// stops and writes nothing more → `{cancelled: n}`. pub fn cancel_exports(app: &mut VectorcraftApp) -> Value { @@ -29,11 +56,16 @@ pub fn hide_exports(app: &mut VectorcraftApp) -> Value { /// options…): every file it writes (one per artboard, linked images) goes next to `path`. The /// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are /// several). When automation asked for the export, `warnings` also name each option the written -/// format doesn't read, as `document.export`'s do. +/// format doesn't read, as `document.export`'s do. With [`EXPORT_SELECTION`] the file holds the +/// selection alone, cropped to it, as `document.exportSelection` writes it. With Appearance of +/// Black › Printing / Exporting set to rich black, RGB files draw a CMYK black as RGB black, as in +/// `document.export`. pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result { // Options given as anything but an object are none: the path and format still reach the // encoder (an exporter plug-in is told the file it writes). let params = &if params.is_object() { params.clone() } else { json!({}) }; + let (selection, params) = take_selection(params)?; + let params = ¶ms; let f = fileio::writable_format(format, path.as_deref())?; // A Shape Builder merge still being computed for a click is exported with the document. let r = app.session.finish_tool_edit(); @@ -42,9 +74,13 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option st.doc.clone(), - std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), + let doc = if selection { + std::sync::Arc::new(fileio::selection_doc(st, "document.exportSelection", f.id == "txt").map_err(|e| e.to_string())?.0) + } else { + match fileio::export_doc(st, f, params).map_err(|e| e.to_string())? { + std::borrow::Cow::Borrowed(_) => st.doc.clone(), + std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), + } }; // A PDF shows its title in the viewer's title bar only when it is the document's own, not the // file name it was titled after, as `document.export` writes it. @@ -55,6 +91,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, path: Option, path: Option Result, String> { bytes.ok_or_else(|| "no data".into()) } +/// The file an Export Selection writes, picked in a save panel (the web: the suggested name): a +/// typed extension of any format is kept, and names the format. +pub(crate) fn selection_path(app: &mut VectorcraftApp) -> Result { + target_path_keeping(app, None, "png", |_| true) +} + /// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to /// that path (else a picked or suggested name) → (path, the command's result without the data). /// The command runs before a path is asked for, so bad params never open a save dialog, except diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 5e05732b62..7b7f7a6b21 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -171,6 +171,8 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::export_selection(app), "document.exportSelection" => { io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) } diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 255d0836b5..4a8e9910cb 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1331,7 +1331,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.exportAs", "Export As…", "", - "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it", + "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it", ), ( "ui.fileInfoDialog", diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 1e6ca2c8d5..d003fbcbce 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -26,6 +26,8 @@ pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0 /// Insert Aki Left and Right presets, in thousandths of an em: none, a quarter, half and a whole /// full-width space. pub const AKI_PRESETS: [f64; 4] = [0.0, 250.0, 500.0, 1000.0]; +/// Tsume presets, in percent. +pub const TSUME_PRESETS: [f64; 5] = [0.0, 25.0, 50.0, 75.0, 100.0]; /// The stored character style: the selected range's (or the caret's) while the Type /// tool edits text, else the first selected text object's first run (including groups). With it, the paragraph @@ -846,7 +848,7 @@ fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) { /// The Character panel's grid of fields, `w` wide: Font Size, Leading, Kerning and Tracking, /// then with `more` the scales, Baseline Shift and Character Rotation, and a row with the /// Anti-aliasing Method dropdown under Character Rotation (`s`: the style shown), and with Show East -/// Asian Options on a row with Insert Aki Left and Right. With Show Font +/// Asian Options on a row with Insert Aki Left and Right and a row with Tsume. With Show Font /// Height Options on, a row under Font Size holds the Set Font Height Reference dropdown. The /// Properties panel shows its first rows. pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &CharStyle, w: f32, more: bool) { @@ -942,6 +944,14 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & }); } ui.end_row(); + // Tsume: the share of the blank space beside each character's ink taken away. + cell(ui, "→A←", tl!("Tsume"), |ui| { + if let Some(v) = widgets::spin_plain(ui, "ch-tsume", shared(app, |s| s.tsume), "%", 1, fw, 1.0, 0.0, &TSUME_PRESETS) { + // The field stops at the largest value the command takes. + format(app, json!({ "tsume": v.min(vectorcraft_doc::text::MAX_TSUME) })); + } + }); + ui.end_row(); } }); } @@ -1219,20 +1229,40 @@ fn axes_cmd(app: &mut VectorcraftApp, axes: Value, phase: widgets::Live) { } /// The Control bar's type controls while type is selected or edited: the Character link (the -/// Character panel in a popover), the font family and style menus and the Font Size combo. -pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { - let Some((s, _)) = text_style(app) else { return }; - let link = ui - .link(egui::RichText::new(tl!("Character:")).size(12.0).color(Tokens::get(ui.ctx()).text).underline()) - .on_hover_text(tl!("Character options")); +/// Character panel in a popover), the font family and style menus and the Font Size combo. They +/// show whole, with `reserve` left after them, or not at all: it returns false when type is selected +/// or edited and the row has no room for them. +pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) -> bool { + let Some((s, _)) = text_style(app) else { return true }; + if ui.available_width() < control_bar_width(ui) + reserve { + return false; + } + let link = ui.link(control_bar_link_text(ui)).on_hover_text(tl!("Character options")); widgets::popover(&link, link.clicked(), |ui| { ui.set_width(260.0); show_owned(app, ui, crate::font_menu::WheelOwner::ControlPopover(link.id)); }); - font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (150.0, 96.0)); - size_field(app, ui, "cb-font-size", 96.0); + let [family, style, size] = CONTROL_BAR_WIDTHS; + font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (family, style)); + size_field(app, ui, "cb-font-size", size); ui.add_space(4.0); crate::chrome::group_separator(ui); + true +} + +/// The Control bar's widths of the font family menu, the font style dropdown and the Font Size combo. +const CONTROL_BAR_WIDTHS: [f32; 3] = [150.0, 96.0, 96.0]; + +/// The text of the Control bar's Character link, as [`control_bar`] draws it. +fn control_bar_link_text(ui: &Ui) -> egui::RichText { + egui::RichText::new(tl!("Character:")).size(12.0).color(Tokens::get(ui.ctx()).text).underline() +} + +/// The room [`control_bar`]'s link, menus and Font Size combo take in the Control bar's row, with +/// the spacing after each. +fn control_bar_width(ui: &Ui) -> f32 { + let spacing = ui.spacing().item_spacing.x; + widgets::text_width(ui, control_bar_link_text(ui)) + CONTROL_BAR_WIDTHS.iter().sum::() + 4.0 * spacing } /// The Contextual Task Bar's widths of the font family menu, the font style dropdown and the @@ -1666,7 +1696,7 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool { /// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline /// character alignment, Proportional Metrics, Tate-chu-yoko, Warichu, Standard Vertical Roman Alignment and /// No Break off, Tate-chu-yoko Settings Up/Down and Left/Right 0, the default Warichu Settings (2 lines, no -/// line gap, 50%, Auto alignment), Insert Aki Left and Right 0, the Default character direction, +/// line gap, 50%, Auto alignment), Insert Aki Left and Right 0, Tsume 0, the Default character direction, /// Kashidas on, the Default digits and the Em Box font height reference, whether or not Show East Asian /// Options, Show Indic Options and Show Font Height Options are on. Inside an undo group that is /// already open, the reset is part of that group's step. @@ -1686,7 +1716,7 @@ fn reset_panel(app: &mut VectorcraftApp) { "allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align, "proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "tcyUpDown": d.tcy_up_down, "tcyLeftRight": d.tcy_left_right, "akiLeft": d.aki_left, "akiRight": d.aki_right, - "warichu": d.warichu, "warichuLines": d.warichu_lines, "warichuGap": d.warichu_gap, "warichuScale": d.warichu_scale, + "tsume": d.tsume, "warichu": d.warichu, "warichuLines": d.warichu_lines, "warichuGap": d.warichu_gap, "warichuScale": d.warichu_scale, "warichuAlign": d.warichu_align, "standardVerticalRoman": d.standard_vertical_roman, "noBreak": d.no_break, "charDirection": "default", "kashidas": d.kashidas, "digits": d.digits, "fontHeightReference": d.font_height_reference diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index 56472c59b0..cdeafd2b00 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -780,6 +780,41 @@ fn insert_aki_fields_show_with_the_east_asian_options() { assert!(left(&app).iter().all(|v| *v == 1000.0), "{:?}", left(&app)); } +/// Tsume is a field of the panel with the East Asian options only, and shows the selected +/// characters' value, blank where they differ. A value typed past 100 sets 100. +#[test] +fn tsume_field_shows_with_the_east_asian_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "(日本)"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("text.setFormat", json!({"tsume": 37.5})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(!text.contains("→A←") && !text.contains("37.5"), "{text}"); + app.session.prefs.show_east_asian_options = true; + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("→A←") && text.contains("37.5"), "{text}"); + // The characters differ: the field is blank. + app.run("text.setRangeStyle", json!({"id": id, "start": 3, "end": 12, "tsume": 62.5})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(!text.contains("37.5") && !text.contains("62.5"), "{text}"); + app.run("text.setFormat", json!({"tsume": 62.5})).unwrap(); + use super::super::tests_appearance::{click, frame_events, text_rect}; + let ctx = egui::Context::default(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "62.5%").center(), show); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], show); + frame_events(&ctx, &mut app, vec![egui::Event::Text("150".into())], show); + frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], show); + frame_events(&ctx, &mut app, vec![], show); + let tsume = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| r.style.tsume).collect::>(), + _ => panic!("text"), + }; + assert!(tsume(&app).iter().all(|v| *v == 100.0), "{:?}", tsume(&app)); +} + /// The panel menu's Show Font Height Options shows the Set Font Height Reference dropdown, /// which shows the selected type's reference and sets the one picked (one undo step); Hide /// Font Height Options hides it again. @@ -1444,6 +1479,7 @@ fn reset_panel_resets_every_attribute_but_the_font() { s.tcy_left_right != d.tcy_left_right, s.aki_left != d.aki_left, s.aki_right != d.aki_right, + s.tsume != d.tsume, s.standard_vertical_roman != d.standard_vertical_roman, s.no_break != d.no_break, s.char_direction != d.char_direction, @@ -1458,16 +1494,16 @@ fn reset_panel_resets_every_attribute_but_the_font() { "id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15, "underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript", "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "tcyUpDown": 2, "tcyLeftRight": -3, - "akiLeft": 250, "akiRight": 500, "standardVerticalRoman": true, "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi", "fontHeightReference": "capHeight" + "akiLeft": 250, "akiRight": 500, "tsume": 50, "standardVerticalRoman": true, "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi", "fontHeightReference": "capHeight" }), ) .unwrap(); }; let plain = runs(&app)[0].1.clone(); - assert_eq!(differs(&plain, &CharStyle::default()), [false; 24]); + assert_eq!(differs(&plain, &CharStyle::default()), [false; 25]); custom(&mut app); let styled = runs(&app)[0].1.clone(); - assert_eq!(differs(&styled, &CharStyle::default()), [true; 24]); + assert_eq!(differs(&styled, &CharStyle::default()), [true; 25]); app.run("select.set", json!({"ids": [id]})).unwrap(); reset_panel(&mut app); assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]); diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 894b8488f8..c667f01200 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -167,15 +167,19 @@ pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weig /// "Stroke"): a click toggles the Stroke panel in a popover under it, and a drag scrubs the weight /// field after it ([`crate::scrub`]). pub(crate) fn link(app: &mut VectorcraftApp, ui: &mut Ui, label: &str) { - let t = Tokens::get(ui.ctx()); // A drag on it scrubs the weight field after it (#4088). - let resp = crate::scrub::link_label(ui, egui::RichText::new(label).size(12.0).color(t.text).underline()).on_hover_text(tl!("Stroke options")); + let resp = crate::scrub::link_label(ui, link_text(ui, label)).on_hover_text(tl!("Stroke options")); widgets::popover(&resp, resp.clicked(), |ui| { ui.set_width(260.0); show(app, ui); }); } +/// The text of the Stroke [`link`] labeled `label`, as it draws it and the Control bar measures it. +pub(crate) fn link_text(ui: &Ui, label: &str) -> egui::RichText { + egui::RichText::new(label).size(12.0).color(Tokens::get(ui.ctx()).text).underline() +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let st = current_stroke(app); @@ -491,12 +495,15 @@ pub fn paint_profile(ui: &Ui, r: Rect, points: Option<&[(f64, f64, f64)]>, color ui.painter().with_clip_rect(r).add(egui::Shape::mesh(mesh)); } +/// The width of the [`profile_dropdown`] button. +pub(crate) const PROFILE_DROPDOWN_WIDTH: f32 = 100.0; + /// The Profile dropdown (Stroke panel, Control bar): the stroke's own silhouette on the button /// (a custom profile too), the built-in profiles and then the saved ones in the list. Returns the /// id or name picked. pub(crate) fn profile_dropdown(app: &VectorcraftApp, ui: &mut Ui, cur: Option<&WidthProfile>) -> Option { let t = Tokens::get(ui.ctx()); - let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(PROFILE_DROPDOWN_WIDTH, 24.0), Sense::click()); ui.painter().rect_filled(r, 2, t.input); ui.painter().rect_stroke(r, 2, Stroke::new(1.0, if resp.hovered() { t.text } else { t.input_border }), StrokeKind::Inside); let body = Rect::from_min_max(r.min + vec2(6.0, 5.0), pos2(r.right() - 20.0, r.bottom() - 5.0)); diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index ff249bbed4..d4a51799ca 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -522,7 +522,9 @@ fn the_wheel_steps_the_families_in_properties_and_the_control_bar() { type Draw = fn(&mut VectorcraftApp, &mut egui::Ui); let places: [(&str, Draw); 2] = [ ("Properties", |app, ui| crate::panels::properties::type_sections(app, ui)), - ("Control bar", |app, ui| crate::panels::character::control_bar(app, ui)), + ("Control bar", |app, ui| { + crate::panels::character::control_bar(app, ui, 0.0); + }), ]; let at = |f: &str| FontDb::global().menu_family_list().iter().position(|x| x == f); for (place, draw) in places { diff --git a/crates/ui-egui/src/tests/saveext.rs b/crates/ui-egui/src/tests/saveext.rs index 815c1c8493..df003ddb01 100644 --- a/crates/ui-egui/src/tests/saveext.rs +++ b/crates/ui-egui/src/tests/saveext.rs @@ -378,6 +378,12 @@ fn every_write_through_a_save_panel_asks_before_replacing() { let d = app.ui.dialog.clone().expect(cmd); assert_eq!((d.kind.as_str(), d.str("path")), (dialogs::confirm::REPLACE, path.to_string()), "{cmd}"); dialogs::confirm(&mut app).unwrap(); + // Export Selection without params continues in the format's options, whose OK writes it. + if cmd == "document.exportSelection" { + assert_eq!(app.ui.dialog.as_ref().map(|d| (d.kind.as_str(), d.str("path"))), Some(("pngOptions", path.to_string())), "{cmd}"); + assert!(w.borrow().is_empty(), "{cmd}: nothing is written before the options' OK"); + dialogs::confirm(&mut app).unwrap(); + } assert_eq!(last(&w), path, "{cmd}"); } // Export As: the question's OK picks the file again, and PNG Options follows. @@ -480,5 +486,8 @@ fn an_export_keeps_a_typed_extension_of_the_format_it_writes() { let (mut app, w) = saving_app("/tmp/sel.pdf"); app.run("select.all", json!({})).unwrap(); app.run("document.exportSelection", json!({})).unwrap(); + // Save PDF follows the save panel, and its OK writes the file. + assert_eq!(app.ui.dialog.as_ref().map(|d| (d.kind.as_str(), d.str("path"))), Some(("savePdf", "/tmp/sel.pdf".to_string()))); + dialogs::confirm(&mut app).unwrap(); assert_eq!(last(&w), "/tmp/sel.pdf", "Export Selection takes its format from the typed name"); } diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 9768813a70..30757d3f52 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -536,6 +536,103 @@ fn the_task_bar_follows_the_active_document() { assert!(!locked(&app, rect), "the button doesn't lock the shape"); } +/// A blank canvas, a new document with a selection tool in use, offers Draw (Pencil) and Place +/// Image centered under the active artboard (under the canvas's center when the artboard is out of +/// view), with the handle and More Options but none of a selection's actions or its Fill, Stroke, +/// Recolor Artwork and Lock. Other tools, artwork in the document and a selection each leave the +/// blank canvas actions out; ruler guides do not count as artwork. +#[test] +fn a_blank_canvas_offers_draw_and_place_image() { + let mut app = app(); + let board = app.session.active().unwrap().doc.artboards[0].rect; + // The artboard at 50% in the middle of the window, after the first frame fits it in. + bar(&mut app); + let view = app.view_mut().unwrap(); + (view.center, view.zoom) = (board.center(), 0.5); + for tool in ["selection", "directSelection", "groupSelection"] { + app.run("tool.select", json!({ "tool": tool })).unwrap(); + let (ctx, texts) = bar_texts(&mut app); + assert!(at(&texts, "Draw (Pencil)").x < at(&texts, "Place Image").x, "{tool}: {texts:?}"); + let shown = canvas::task_bar_rect(&ctx).unwrap(); + let xf = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let bottom = xf.quad(board).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); + let middle = xf.to_screen(board.center()).x; + assert!(shown.top() > bottom && (shown.center().x - middle).abs() < 2.0, "{tool}: {shown:?} centered under {board:?}"); + assert!(app.canvas_rect.unwrap().contains_rect(shown), "{tool}: {shown:?} on the canvas"); + } + // The artboard out of view: under the canvas's center. + app.view_mut().unwrap().center = board.center() + vectorcraft_geom::Vec2::new(5000.0, 5000.0); + let (ctx, _) = bar(&mut app); + let (shown, middle) = (canvas::task_bar_rect(&ctx).unwrap(), app.canvas_rect.unwrap().center()); + assert!((shown.center().x - middle.x).abs() < 2.0 && shown.top() > middle.y && shown.top() < middle.y + 60.0, "{shown:?} under {middle:?}"); + app.view_mut().unwrap().center = board.center(); + + let tree = crate::widgets::accessibility_tests::tree(|ui| canvas::show(&mut app, ui)); + for label in ["Draw (Pencil)", "Place Image", "More Options"] { + assert_eq!(crate::widgets::accessibility_tests::named(&tree, label).role(), egui::accesskit::Role::Button, "{label}"); + } + let named = |label: &str| tree.iter().any(|n| n.label().is_some_and(|l| l.starts_with(label))); + for label in ["Fill", "Stroke", "Recolor Artwork", "Lock", "Duplicate"] { + assert!(!named(label), "{label} is left out"); + } + + for tool in ["pen", "rectangle", "type", "artboard"] { + app.run("tool.select", json!({ "tool": tool })).unwrap(); + let (_, texts) = bar_texts(&mut app); + assert!(!has(&texts, "Draw (Pencil)") && !has(&texts, "Place Image"), "{tool}: {texts:?}"); + } + + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let rect = id(app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap()); + select(&mut app, &[rect]); + let (_, texts) = bar_texts(&mut app); + assert!(has(&texts, "Duplicate") && !has(&texts, "Draw (Pencil)"), "{texts:?}"); + select(&mut app, &[]); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "artwork with nothing selected: no bar"); + select(&mut app, &[rect]); + app.run("edit.clear", json!({})).unwrap(); + app.run("guide.add", json!({"pos": 50})).unwrap(); + assert!(has(&bar_texts(&mut app).1, "Draw (Pencil)"), "a ruler guide is not artwork"); +} + +/// Draw (Pencil) switches to the Pencil tool. Place Image picks files for the Place dialog; +/// canceling the pick leaves the document as it was. +#[test] +fn the_blank_canvas_bar_draws_with_the_pencil_and_places_an_image() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + click(&mut app, "Draw (Pencil)"); + assert_eq!(app.session.tool_id(), "pencil"); + + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + app.services.pick_open = Some(Box::new(|_: &crate::FilePick| None)); + click(&mut app, "Place Image"); + assert!(app.ui.dialog.is_none(), "canceled: no Place dialog"); + assert!(app.session.active().unwrap().doc.layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty())), "nothing placed"); + + let path = std::env::temp_dir().join("vectorcraft-task-bar-place.png").to_string_lossy().into_owned(); + let picked = path.clone(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + click(&mut app, "Place Image"); + let d = app.ui.dialog.take().expect("the Place dialog"); + assert_eq!((d.kind.as_str(), &d.fields["files"]), (crate::dialogs::place::KIND, &json!([{ "path": path }]))); +} + +/// On a canvas too narrow for one row, the blank canvas bar's actions wrap onto a second row and the +/// bar stays whole on the canvas. +#[test] +fn the_blank_canvas_bar_wraps_on_a_narrow_canvas() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (texts, r, canvas) = bar_texts_on_screen(&mut app, &ctx, vec2(240.0, 400.0), 3); + assert!(canvas.contains_rect(r), "{r:?} on {canvas:?}"); + let (draw, place) = (at(&texts, "Draw (Pencil)"), at(&texts, "Place Image")); + assert!(place.y > draw.y + 20.0, "Place Image wraps under Draw (Pencil): {draw:?} {place:?}"); +} + /// The Type tools whose bar shows the font and Fill controls. const TYPE_TOOLS: [&str; 6] = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"]; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index fdcc335b2c..cc52d58ed7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -123,7 +123,7 @@ the two can still win that race. | Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call; an `app.export` given an option the format doesn't read: also `warnings`, naming the option), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. A job that fails reports the error in the status bar only (`ui.inspect` → `ui.status`), a path outside `--automation-write-root` included. With the preferences off (`prefs.set`), saves return once written. `app.save {path?, wait: true}` and `app.export {path, wait: true}` wait for the jobs already running, write the file before they reply whatever the preferences are set to, and return its result or the error. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the preferences. A `wait` that is not `true`, `false` or `null` is refused with "`wait` must be true or false", and nothing is written | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes | | Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`). The dialog has the format presets menu (☰). In the dialog, `exportPreset` names the preset applied last (`ui.dialog.set` of it applies the preset it names, as picking it does; a name that is not a preset's is an error and leaves the dialog as it was), `__exportPresets` lists the presets, and `__savePreset` holds the name being typed after Save Preset…. The panel menu applies presets through `assets.settings.set {exportPreset}` and holds Save As Preset…, Save Changes, Delete Preset, Import Preset… and Export Preset…. Import and Export use picked files. | -| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog | +| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` with params still exports the selection in one go, without the dialog; with no params or `{}` the save panel picks the file and its format's options dialog follows, as after Export As, and the call returns `null` while the dialog is open (OK returns what `file.exportAs` returns, without `format` or `bounds`). `file.exportAs {path, exportSelection: true, …options}` writes the selection without a dialog | | CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows `warnings` as Export As shows an export's notes | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF, `.ai` or `.ait` with a password, or one with several pages unless it is a `.ai` or `.ait` with its editing data or carries its VectorCraft document (those open whole), or placing (`file.place` without `page`) any of them with several pages or a password, opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | diff --git a/docs/development.md b/docs/development.md index 82ffbd4a5a..5dfd67a7c1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -126,7 +126,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: - **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol (`wl_data_device`) on the window's own connection, and only while the window has focus. One Copy offers the PNG as `image/png`, the SVG as `image/svg+xml` (with Include SVG Code on) and the text as `text/plain;charset=utf-8`, `UTF8_STRING` and `text/plain`; a format with no data is not offered. Paste reads `image/svg+xml`, then text, then `image/png`, and blank text or text that only names a picture gives way to the PNG; the Paste menu item is enabled by the types offered, without reading any data. Whether the clipboard still holds VectorCraft's Copy is told by its data source, which the compositor cancels when another app copies. A read takes 64 MiB at most and fails beyond that. Reads run on a worker thread: text waits 2 s at most on the UI thread, SVG and PNG 10 s on the paste's background thread. An owner that never sends its data costs one read its wait, and later reads return at once until it answers. When a text read gets no answer in its 2 s, the paste waits on its background thread for that read's answer, up to 10 s after the read was sent, then reads the clipboard again unless it holds VectorCraft's Copy. Another app's paste of a Copy is sent on a thread of its own, 8 at once at most; a paste beyond them gets nothing. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. -- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager when one runs. That includes a read of the Paste menu check's handle: quitting waits up to 27 s for the background thread to drop it before the app drops its own, and quits after that wait even when the handle has not been dropped. The app hides its main window before these waits (`hide_before_teardown` in `apps/vectorcraft/src/app.rs`), so an X11 window, an XWayland one included, closes at once; winit cannot hide a native Wayland window, which stays on screen until the waits end. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. +- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager when one runs. That includes a read of the Paste menu check's handle: quitting waits up to 27 s for the background thread to drop it before the app drops its own, and quits after that wait even when the handle has not been dropped. The app takes its windows off screen before these waits (`apps/vectorcraft/src/quit_windows.rs`). An X11 main window, an XWayland one included, is hidden. winit cannot hide a native Wayland window, so on Linux the app attaches no buffer to the main window's surface, which unmaps it while the window and its connection stay open for the Wayland clipboard. On Linux the X11 windows of floating panels and documents are unmapped through a second X11 connection. On native Wayland, floating panels and documents are normally drawn inside the main window; on the BSDs a native Wayland main window stays on screen until the waits end. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own. - **Pens and pen displays under Wayland (#491, #2028):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336), commit [25c65abd](https://invent.kde.org/plasma/kwin/-/commit/25c65abddd95eb87fb892a8594faccbf509ff62f)), and GNOME's Mutter does not turn it into pointer input either (that commit's message): on KWin the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, the app runs under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer: Preferences › Performance › Linux Display Server's Automatic (the default) and X11 (XWayland) When a Pen Is Attached (`x11WithPen`) open the window through XWayland when `/proc/bus/input/devices` lists a pen as the app starts, a device with the `BTN_TOOL_PEN` key and the `ABS_X` and `ABS_Y` axes, and log `pen found: opening the window through XWayland`, as PhotoCraft does (#4967; `VECTORCRAFT_NATIVE_WAYLAND=1` keeps the window on Wayland). For a pen attached after the app started, either run the app under XWayland (Preferences › Performance › Linux Display Server › X11 (XWayland); `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse. Under XWayland the app also reads the pen's pressure (next item); with KWin's emulation on native Wayland the pen has no pressure. Other compositors that don't emulate a pointer for tablets behave the same way. @@ -294,7 +294,10 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE control channel, MCP and tests read them) and are translated only where the status bar draws them (`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as `Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the - placeholders are translated in turn (the reason after `: {e}` is often a message too). Czech, German, Greek, Spanish, French, Georgian, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish, Turkish, Catalan and Dutch + placeholders are translated in turn (the reason after `: {e}` is often a message too). A literal brace in a + row is written `{{` or `}}`, in the English and in the translation: a message made with `format!` keeps the + doubled braces of its format string, and a plain string literal has each of its braces doubled, so the row for + `"expected {\"objects\": [...]}"` is `expected {{"objects": [...]}}`. Czech, German, Greek, Spanish, French, Georgian, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish, Turkish, Catalan and Dutch translate status and error messages (`COMPLETE_MESSAGES` in `i18n::tests`); the untranslated report lists the message literals the scan finds, and the phrases a parameter schema's error builds at run time (`SCHEMA_PHRASES`), that one of them has no row for. The scan covers the crates in `MESSAGE_CRATES`, the diff --git a/docs/effects-parity.md b/docs/effects-parity.md index b546cb557e..1457e35bc6 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -178,7 +178,7 @@ VectorCraft choices; an original approximation, never compared with another appl | Item | Ours | |---|---| -| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | +| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, overlapping faces drawn in the order of their depths where they overlap, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, round seam joins, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); the Materials tab's Graphics, document symbols mapped onto Extrude & Bevel's caps and onto a plane, moved with an artboard handle, and wrapped around Revolve's side ([three-d.md](three-d.md#graphics)); no base materials, graphics on Inflate or on Extrude & Bevel's walls and bevels, soft shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fe1dbab127..7684f58942 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -116,15 +116,16 @@ private editing data. VectorCraft: 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. 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, + art's colors, Art brushes that run their art from left to right without overlap at a fixed width, with their + width, flips and scale (Stretch Between Guides with guides that leave art between them), 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 + with an Art brush whose art bends along the path and that doesn't scale its art proportionately, 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. + its stroke; then the brushes are written, and the file opens without the new document's Pattern and Bristle + brushes. 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, @@ -264,7 +265,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -The app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile on a Stroke Offset stroke or written as each side's widths) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way when nothing in the object is stroked: up to 180° either way (a limit VectorCraft sets) on a path or compound path whose segments are all straight, except a compound path with a guide member that reaches past its other members at an angle other than 0°, and up to 90° either way (a limit VectorCraft sets) on another path, compound path or group with no guide in it. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a variable-width stroke with one centered width array, dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +The app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile on a Stroke Offset stroke or written as each side's widths) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [layered image file format specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way when nothing in the object is stroked: up to 180° either way (a limit VectorCraft sets) on a path or compound path whose segments are all straight, except a compound path with a guide member that reaches past its other members at an angle other than 0°, and up to 90° either way (a limit VectorCraft sets) on another path, compound path or group with no guide in it. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a variable-width stroke with one centered width array, dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush is in Tints or None, at a fixed width from 1% to 1,000%, stretched to the path or stretched between guides that leave art between them, flipped along or across or not ([Art brush options](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf), printed page 193), with `Spacing` 0, `Adjustment` 1 and `FoldAdjustment` 1 (VectorCraft's own reading of these numbers; a brush that scales its art proportionately comes into the Brushes panel with that option, and its paths keep their drawn look); the path's settings are the brush's own (apart from the lowest and highest width of a fixed width and the guides' lengths without Stretch Between Guides, which don't draw), with no entry that isn't read, no variable width and no effects; a stroke in Tints isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an EPS with editing data from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so live objects that only the editing data holds arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (the PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. A run of type in the editing data whose baseline direction is 1 opens with Standard Vertical Roman Alignment (https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) on, which is VectorCraft's own mapping: its half-width characters stand upright in vertical type, and horizontal type keeps the mark with no change to its layout. A run whose baseline direction is 2 or 3 (Tate-chu-yoko), or that names none in the run, its character style or the document's defaults, opens with it off. The scripting reference lists 1, 2 and 3 as the Standard, vertical rotated and Tate-chu-yoko baseline directions (https://web.archive.org/web/20170621180804/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20VBScript%20Reference.pdf, page 256). A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. diff --git a/docs/gaps.md b/docs/gaps.md index 2d2a394bdd..8aea143a47 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -213,10 +213,12 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi weight, where the object's brush settings are the brush's own and its angle is on the page (#372, from a user's file of 6D Art Pen strokes: all 455 of its brushed styles), and a Calligraphic brush's Tilt, Bearing and Rotation (codes 4, 5 and 6) come in as those channels instead of Random. Others keep their drawn look. - Paths with an Art brush open the same way when the brush stretches art of solid-filled paths along the path, - unflipped, at 100% in Tints, and the path has none of its own settings, variable width, effects or a swatch - stroke. Other Art brushes (None or another colorization, another width, Proportional, between guides, flipped, - art with strokes, gradients or patterns) and type with an Art brush keep the drawn look. + Paths with an Art brush open the same way when the brush bends art of solid-filled paths along the path, and + the path has none of its own settings, variable width, effects or a swatch stroke in Tints. The brush may be + in None or Tints, at a fixed width, stretched to the path or between guides, and flipped along or across. + Other Art brushes (Tints and Shades or Hue Shift, a width that follows the pen, Scale Proportionately, guides + that leave no art between them, a `Spacing`, `Adjustment` or `FoldAdjustment` other than 0, 1 and 1, art with + strokes, gradients or patterns) and type with an Art brush keep the drawn look. - **Done (2026-11-12):** Explicitly requested editing data retains a single artboard larger than a capped 14,400 pt PDF page (#6865); the default import and other size mismatches remain unchanged. Synthetic importer regression; scores unchanged. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead diff --git a/docs/mcp.md b/docs/mcp.md index f6a30394ff..15a4d8575a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -836,9 +836,13 @@ them sets what the graph stores. While Global Edit is on, a path directly in a s match (a graph's paths are matches only while the graph is isolated) has what changed on it stored for its series: the paint, weight, opacity and blend mode of the topmost visible fill and stroke, and the object's opacity and blend mode. `addon.arrange.randomizeOpacity` and `addon.arrange.sort` with `apply: "opacity"` store the object opacity, with the -blend mode, of the frontmost path of a series whose opacity they change. A gradient is stored with its placement, and -every path of the series takes that placement when the graph is drawn again. The graph does not store a stroke's -dashes, caps, joins, arrowheads, width profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again +blend mode, of the frontmost path of a series whose opacity they change. A gradient is stored with its placement relative +to the box of the path it was stored from (a stroke's box is the path's bounds grown by half the weight), and every path +of the series, the legend swatch included, takes it at the same place on its own box when the graph is drawn again, also +after the data changes or the graph moves. The graph saves this as `fillUnitBox` and `strokeUnitBox` in its series +paints. A gradient stored from a path with no width or no height is drawn at the place in the document where it was +stored. So is a gradient a graph stored without these flags. The graph does not store a stroke's dashes, caps, joins, +arrowheads, width profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again with the paint they had. `text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes @@ -1070,7 +1074,14 @@ apps (`editingData`), and always comes back with the warning `Preserve editing w bounds would replace the document's artboards when reopened`; with `pdfCompatible: false` it is refused, whatever `editingData` says. A legacy version 7 `.ai` (`aiVersion: "7"`) of the art's bounds is written as before. Export As PDF continues in the Save PDF dialog with Use Artboards on or off; with it off, the dialog has no -Artboards section and the page is the bounds of the visible art. +Artboards section and the page is the bounds of the visible art. `document.exportSelection` run in the app with no +params or `{}` (a shortcut, or an automation call) opens the save panel, and the picked format's options dialog +follows as after Export As: Save PDF without its Artboards section, SVG Options without its artboards, the raster +options, EPS Options, DXF Options without Export Selected Art Only, Text Export Options with Selection Only on, or an +exporter plug-in's parameters. Other formats are written at once. OK writes the selection alone, cropped to it, as +`document.exportSelection` writes it, with the chosen options. While a dialog is open the call returns `null`, and +OK returns what `file.exportAs` returns, which has no `format` or `bounds` (Text Export Options: `{path}`). To +write without a dialog, pass `path` or any option. `file.exportAs {path, exportSelection: true, …options}` writes the selection the same way. Raster exports default to the document's raster effects resolution. An explicit `scale` sets pixels per point (72 times that value in ppi); `options.ppi` wins over either scale option. CLI `run --export` and @@ -1801,6 +1812,13 @@ commands fail and change nothing, and `appearance.removeItem` removes nothing. A the item last. A text `start` or `end` is past the end of the text. `object.slice.divide` refuses such a `rows` or `columns`, `recolor.reduce` keeps each color in its own row for such a `colors`, and `layer.setProps` refuses such a `color`. `object.setBounds` reads a `reference` past 8 as 8, as `object.transformEach` does. +An index or place given as a value that is not a whole number of 0 or more, such as `"3"`, `2.5`, `-1` or `true`, is +refused with `` `anchor` must be a whole number ``, naming the key: `anchor` of `path.setHandle`, `path.moveAnchor` and +`text.setPathHandle`, `segment` of `path.insertAnchor`, `to` of `artboard.reorder`, `library.moveGroup` and +`graphicStyle.move`, `subpath` of `path.appendAnchor`, `path.close` and `path.reverse`, and `artboard` of +`document.rulerOrigin`, `document.setRulerOrigin` and `document.resetRulerOrigin`. A required one left out or null is +reported as missing (`missing anchor`). Left out or null, `subpath` takes the command's default and `artboard` is 0. +Nothing changes and no undo step is recorded. The blend, envelope and gradient mesh commands refuse a string or another value that is not a number with the same message, as in `` `steps` must be a number, not "8" ``, and change nothing: `steps`, `distance` and `value` in `object.blend.make` and `object.blend.options`; `bend`, `h`, `v`, `horizontalDistortion` and `verticalDistortion` in @@ -3697,7 +3715,9 @@ A graphic style holds an appearance (fills, strokes, effects) plus opacity, blen object's, type its characters'). `graphicStyle.apply {name, ids?}` gives the objects the style and links them; `add: true` adds the style on top of the existing appearance instead. Linked objects stay linked while they keep the style's look: editing their appearance or transparency breaks the link, and `graphicStyle.redefine {name?, id?}` -updates only the objects still linked. `graphicStyle.list` returns each style with the ids linked to it and the +updates only the objects still linked. The art of a column or marker design in a graph series is never linked: +`graphicStyle.new` can make a style from it, and `graphicStyle.redefine` leaves its look as it was and unlinks it, +whether it is the source or a linked object. `graphicStyle.list` returns each style with the ids linked to it and the style of the first selected object; `select.same.graphicStyle` selects an object's fellow users. Styles keep placed gradients relative to the bounds of the object they were made from, and each object a style is applied to gets them at the same place relative to its own bounds. @@ -4805,7 +4825,10 @@ type, with the image under it. A group's or layer's own fills and strokes become members are expanded too. A symbol instance whose topmost fill or topmost stroke is visible, has an opacity above 0 and has a geometry effect becomes a group of its art: that fill's effects reshape the filled shapes of the art and that stroke's effects reshape its stroked shapes, which keep their colors, as the canvas draws them (VectorCraft's own -rule); the instance's other fills and strokes are dropped. The result is `{ids}`; when an image in the art can't be decoded, the images made from +rule); the instance's other fills and strokes are dropped. A raster effect on that fill or stroke draws on the filled +or stroked shapes of the art, and expanding makes it an image as for other objects; with no geometry effect, the +instance stays an instance over a shadow or glow image, and a blur, feather or inner glow replaces it with the image. +The result is `{ids}`; when an image in the art can't be decoded, the images made from raster effects do not show it, and the result also carries the `warning` that Flatten Transparency gives. A path's strokes stay strokes, as the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help @@ -8797,6 +8820,28 @@ Insert Aki values; save it with Preserve Editing to keep them editable. {"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"akiRight":500}}} ``` +## Tsume + +`text.setFormat` and `text.setRangeStyle {tsume?}` set Tsume of the characters, a percentage that narrows the space +around them ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)). The range, +the default and how the space is measured are VectorCraft's own rules: the value is from 0 to 100, 0 by default and +saved only when not 0, and it is the share of the blank space on each side of a character's ink, inside the +character's own width, that is taken away. 50 halves that space, and 100 sets the ink against the characters before and +after it; the glyph keeps its shape. A character without ink, such as a space, keeps its width. A ligature or a letter +with its marks takes the value of its first character. Tracking, kerning and Insert Aki are added after it, and +justification keeps it. Tsume applies to horizontal type; vertical type keeps its spacing. Character styles carry it +(`charStyle.new`, `charStyle.setAttrs` and `charStyle.edit` refuse a value outside the range). A value that is not a +number is refused with `` `tsume` must be a number ``, and one outside the range with `` `tsume` must be from 0 to +100 ``. One undo step, and a call that gives the value the characters already have records none. With Show East Asian +Options on, the Character panel shows a Tsume field, blank where the selected characters differ, with 0, 25, 50, 75 and +100 in its menu, and a value typed past 100 sets 100; Reset Panel sets it to 0. Character Style Options shows it in +Advanced Character Formats. Live SVG text places each character Tsume moves, and type on a path with Tsume is written +as outlines. + +```json +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"tsume":50}}} +``` + ## Composite Fonts A composite font sets each class of character in a font of its own @@ -9500,7 +9545,7 @@ attribute map in one undo step. `to` is trimmed and must be unique. The command while preserving local overrides; Undo/Redo restores the definition and its users. The `charStyleOptions` dialog exposes `name`, `font_family`, `font_style`, `size`, `leading`, `kerning`, `tracking`, `case`, `position`, `underline`, `strikethrough`, `charAlign`, `h_scale`, `v_scale`, `baseline_shift`, `tcyUpDown`, -`tcyLeftRight`, `rotation`, `akiLeft`, `akiRight`, `language`, `fill`, `fill_tint`, `stroke`, `stroke_tint`, `stroke_width`, `overprint_fill`, `overprint_stroke`, +`tcyLeftRight`, `rotation`, `akiLeft`, `akiRight`, `tsume`, `language`, `fill`, `fill_tint`, `stroke`, `stroke_tint`, `stroke_width`, `overprint_fill`, `overprint_stroke`, `ot_figure`, `ot_position`, `ot_liga`, `ot_calt`, `ot_dlig`, `ot_swsh`, `ot_salt`, `ot_titl`, `ot_ordn`, `ot_frac`, `ot_sets`, `section` (`general`, `basic`, `advanced`, `color` or `opentype`), and `preview`. An empty field leaves its attribute unset. `leading` and `kerning` also accept `Auto` to store an explicit automatic value. @@ -9508,7 +9553,8 @@ Advanced Character Formats holds `h_scale` and `v_scale` (percent, 1 to 10000), `baseline_shift` (points, -1296 to 1296), the Tate-chu-yoko Settings `tcyUpDown` and `tcyLeftRight` (points, -1296 to 1296, as `text.setRangeStyle` takes them; an explicit 0 stays distinct from unset), `rotation` (degrees, stored from -180 to 180), Insert Aki Left and Right `akiLeft` and `akiRight` (thousandths of an em, with the Character panel's 0, 250, 500 and 1000 in their menus; a value typed below 0 or past 1000 sets 0 or -1000) and `language` (a name or tag from Document +1000), Tsume `tsume` (percent, with the Character panel's 0, 25, 50, 75 and 100 in its menu; a value typed below 0 or +past 100 sets 0 or 100; an explicit 0 stays distinct from unset) and `language` (a name or tag from Document Setup's languages; English: USA stores the explicit default `null`, and a tag not in the list that the style already has is kept). Language sets the language the characters are shaped in; it does not change hyphenation or spelling. @@ -10496,9 +10542,13 @@ give the same result there. A paragraph whose characters differ in line metrics scale, or an inline graphic taller than the text) is composed with Every-line in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line that reaches where the frame is -narrower, or beside a wrap object, gets the room there. Such a paragraph falls back to Single-line when the frame has -no room for all of it, or when the search for its breaks passes VectorCraft's own limits (1,000,000 lines tried and -2,000,000 baseline steps for one paragraph, 2,000,000 lines and 4,000,000 steps for all the paragraphs of a frame). +narrower, or beside a wrap object, gets the room there. When the frame has no room for all of such a paragraph, the +lines it holds are still composed with the whole paragraph: VectorCraft measures the lines past the frame's end as +wide as the span of the last line the frame holds, less their indents. Those lines overflow, or go on in the next +threaded frame, which composes them again at its own widths. Such a paragraph falls back to Single-line when the +frame has no room for its first line, or when the search for its breaks passes VectorCraft's own limits (1,000,000 +lines tried and 2,000,000 baseline steps for one paragraph, 2,000,000 lines and 4,000,000 steps for all the +paragraphs of a frame). The UI command `type.lineComposer` (Cmd+Alt+Shift+C, in remote mode `press_key {key: "C", mods: {cmd: true, alt: true, shift: true}}` or `invoke_menu`) sets `singleLine` when every paragraph of the selected type (or every paragraph the Type tool's selection touches) uses Every-line, else `everyLine`, and returns the composer it set. diff --git a/docs/revolve.md b/docs/revolve.md index e52770fd65..7e80ee3e24 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -102,7 +102,7 @@ with the standard command. - One Revolve per source object. Edit its options rather than applying another. - Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint. - Smooth diffuse shading, one to ten directional lights, no textures, cast shadows or physical materials. Smooth shading interpolates lighting in projected triangles; it does not smooth the silhouette or add specular highlights. -- Faces use back-to-front depth sorting. Caps facing the camera cover their rim seams; caps facing away sit behind the body. Intersecting profiles can have incorrect visibility. +- Faces are drawn back to front by the depths of their centers. When that order draws the nearer of two overlapping faces of a surface first, every pair of overlapping faces goes in the order of their depths at the middle of where they overlap; the sort compares at most 4,000,000 pairs (VectorCraft's own limit), and the pairs past that keep the depths of their centers. Caps facing the camera cover their rim seams; a cap facing away is drawn behind the body except where it is nearer than a body face it overlaps. Intersecting profiles can have incorrect visibility. - Curves use sixteen samples per segment, and as few as four when a surface would otherwise pass its limits. Surfaces are limited to 16,384 profile points and 163,840 faces, the members of a group together. A straight segment takes one point, a curve takes at least four, and each contour takes one. Smooth shaded side quads become two triangles and share the face limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit. - Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile. - Extrude & Bevel, Inflate and Rotate are described in [three-d.md](three-d.md). Materials remain unimplemented. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ceedc1962b..ad7e9f4eab 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -626,6 +626,7 @@ since 2026-10-01. - **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged. - **`.ai` files keep a linked image's Warp live and its link:** a linked image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp, as an embedded one is: its bent pieces are its drawn look, and the image itself follows on `%_` lines as a placed file with its path, its folder relative to the document's and its preview. Before, it was saved as its drawn art and its live Warp was lost. Opening the file reads it as the linked image with its Warp live, its link, pixels, placement, name, opacity, hide and lock kept, under the same drawn-look comparison; a hidden image with a Warp, embedded or linked, is now compared with its look drawn shown, and keeps its Warp too. A linked image with a Warp in an opacity mask's art keeps its Warp and is embedded, with the existing warning for linked images in masks. Linked files stay connected to their source and track it by relative path ([the help page](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/manage-linked-and-embedded-files/links-panel-overview.html)); the placed-file form is the one the `.ai` reader reads (`crates/eps/src/import/ai/read/art.rs` `end_place`). eps `ai_editing::tests::a_linked_image_with_a_warp_reads_back_linked_with_its_warp_live`, `a_large_linked_image_with_a_warp_reads_back_from_its_preview`, `a_hidden_or_locked_image_with_a_warp_keeps_its_state`, `a_linked_image_with_a_warp_in_a_mask_is_embedded_with_its_warp_live`. Scores and hours unchanged. - **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged. +- **EPS writes a repeated freeform gradient's image once:** a freeform gradient whose image samples the same on more than one fill (copies of a shape moved with their points, or one shape in a symbol's instances) has its samples written once in the setup, as a repeated image's are, and a fill whose image differs (other colors, another size) gets its own. A freeform fill drawn once is written where it is drawn, as before, and a fill whose placement cannot be drawn, or that arrives when the page is already at the 256 MB limit, is no longer sampled first (eps `tests::a_freeform_gradients_image_drawn_twice_writes_its_samples_once`, `tests::freeform_gradients_sampled_differently_dont_share_an_image`, `tests::a_field_taken_back_at_the_page_limit_leaves_no_image_behind`). Scores and hours unchanged. - **PDF writes a warped image once:** with downsampling on, the pieces of an image bent by a Warp effect are resampled for the widest and the highest size any piece places the image at, so every piece paints one image XObject (a colorized image: one colorized image and its soft mask), instead of one image for each size a piece is placed at. Another placement of the same image is still resampled for its own size. Without downsampling, a colorized image and a CMYK image with a grayscale image mask on its grid are decoded and encoded once for all the sizes they are placed at (pdf `tests::compression::a_warped_image_is_written_once_for_all_its_pieces`, `tests::colorize::a_colorized_warped_image_is_written_once_for_all_its_pieces`, `export::images::tests::colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling`). 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. @@ -646,6 +647,7 @@ since 2026-10-01. - **Control bar docks to the top or the bottom, or floats:** the Control bar's panel menu, at the bar's right end, has Dock to Top and Dock to Bottom, the edge in use checked and neither while the bar floats. Dragging the bar's gripper and releasing it within 24 points of the top (under the menu bar) or the bottom of the window docks it at that edge, with a band along that edge while it moves; releasing it farther from both edges floats the bar there, with its outline under the pointer while it moves, and a floating bar's gripper moves it or docks it again (`window.control.dock {edge: "top"|"bottom"|"float"}`), as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). Escape cancels a gripper drag. The 24-point zones, the floating bar's 800-point width and first position, a release outside the window leaving the bar where it was, a window too short for a floating bar docking it at the nearer edge, the floating bar staying inside a window that shrinks, and keeping the edge and the floating position with the workspace, with older workspaces docking the bar at their edge, are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom`, `dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge`, `dragging_the_control_bar_gripper_away_from_both_edges_floats_it`, `floating_control_bar_moves_and_redocks_from_each_edge`, `floating_control_bar_menu_docks_to_either_edge`, `control_bar_drag_escape_restores_placement`, `control_bar_drag_release_outside_window_keeps_previous_placement`, `floating_control_bar_stays_reachable_in_tiny_and_resized_windows`, `the_control_bar_dock_command_takes_top_bottom_or_float` and `menus_stop_above_a_control_bar_docked_at_the_bottom` in `crates/ui-egui/src/chrome/tests.rs`, `saved_workspaces_restore_the_control_bar_edge` and `saved_workspaces_restore_floating_control_bar_position` in `crates/ui-egui/src/workspaces.rs`. - **Control bar panel menu turns groups of options on or off:** the Control bar's panel menu lists the bar's groups of options (Snapping, Anchor Points, Tool Options, Object Options, Image Options, Fill and Stroke, Opacity, Graphic Style, Character, Paragraph, Vertical Alignment, Align and Distribute, Transform, Artboard Options and Document Options) above Dock to Top and Dock to Bottom, each checked while it is on; a click turns that group off or on (`window.control.options {group, visible?}`), and a group that is off stays out of the bar even while its tool or object is selected, as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). The list of groups and what each holds, every group on to start, a group that is off leaving its room to the groups after it, and keeping the groups with the preferences and the workspace are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_turns_each_group_on_or_off`, `the_control_bar_keeps_its_label_and_menu_with_every_group_off`, `the_control_bar_panel_menu_scrolls_in_a_short_window`, `the_control_bar_options_command_turns_a_group_on_or_off`, `groups_that_are_off_leave_their_room_to_the_later_groups`, `the_artboard_tool_s_control_bar_follows_its_two_groups` and `the_anchor_points_group_holds_convert_and_anchors` in `crates/ui-egui/src/chrome/tests.rs`, `the_image_options_group_holds_the_image_and_trace_controls` in `crates/ui-egui/src/tests/place.rs`, `the_character_and_paragraph_groups_hold_the_type_controls` in `crates/ui-egui/src/panels/paragraph/tests.rs`, `saved_workspaces_restore_the_control_bar_groups` in `crates/ui-egui/src/workspaces.rs`. - **Control bar separators stand only between groups:** the Control bar draws a separator between two groups of options only when a control follows it in the bar. When the groups after a separator are off in the panel menu, do not apply to the selection or the tool, or have no room in the window, the bar ends with the last control and no separator; two separators with nothing between them show as one. Each separator keeps its room, so the groups keep their places. This is VectorCraft's own rule. Regression test: `no_separator_is_left_at_the_end_of_the_control_bar` in `crates/ui-egui/src/chrome/tests.rs`. +- **Fill and Stroke and Opacity stay inside a narrow Control bar:** the Control bar leaves out a Fill and Stroke, Opacity or Graphic Style control it has no room for, whole, instead of drawing it past the panel menu button or under it. The bar draws its controls from left to right, each only when the room left still holds it, so a narrower control can still show after a wider one that was left out: the Opacity link with its field can show when the Stroke link with its weight field did not fit, the Style link with its chip can show when the Opacity link with its field did not, and with nothing selected Preferences can show when Document Setup did not fit. With something selected, the Fill and Stroke, Opacity, Graphic Style and Character controls show only while the Transform link still has room after them, so the bar keeps the link and leaves those controls out first. The Character controls show whole or not at all, and while they are left out the Paragraph, vertical alignment and Align controls after them are left out too. The Snapping menu, the selection's label, the Convert and Anchors groups, the isolation button, Document Setup and Preferences also show only while they fit, and a group separator only while it has room. This is VectorCraft's own rule. Regression tests: `the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window`, `a_narrow_control_bar_shows_the_style_link_without_the_opacity_field`, `a_narrow_control_bar_shows_preferences_without_document_setup` and `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`, `the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right` in `crates/ui-egui/src/panels/paragraph/tests.rs`. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. - **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`. @@ -863,6 +865,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A click on another object moves the contextual task bar under it without hiding it for the press; it gives way only to a drag (#4681). - With one image selected, the contextual task bar offers Image Trace, which traces the image at once with the Default preset (the Image Trace panel is left as it was), and Crop Image; with an Image Trace object it offers Expand, which keeps only the traced paths. This follows Illustrator's bar: for a selected image it includes Image Trace, Retype and Crop Image, and traced artwork can be expanded from it ([Contextual Task Bar announcement](https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580)). Offset Path and Simplify, which don't apply to an image, aren't offered for one. Not done: Retype, which the app doesn't have. - With the Artboard tool, the contextual task bar sits under the selected artboards and offers Resize (Artboard Options for the active artboard), Duplicate (each selected artboard, in one undo step, the copies then selected), Rearrange All (with two or more artboards) and Export (Export for Screens with the selected artboards checked). This follows Illustrator's bar: while the Artboard tool is in use, it has options for resizing, duplicating and exporting artboards (the [Contextual Task Bar announcement](https://community.adobe.com/questions-652/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580); Rearrange and Export on the bar: [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/)). + - On a blank canvas the contextual task bar offers Draw (Pencil), which switches to the Pencil tool, and Place Image, which picks files for the Place dialog, as the help page lists for a new document (). VectorCraft's own rules: the bar shows them while a selection tool is in use in a document with no artwork (layers and guides only), sits under the active artboard (under the canvas's center when the artboard is out of view), and leaves out the Fill, Stroke, Recolor Artwork and Lock controls. Not done: Generate Vectors. Evidence: `a_blank_canvas_offers_draw_and_place_image`, `the_blank_canvas_bar_draws_with_the_pencil_and_places_an_image` and `the_blank_canvas_bar_wraps_on_a_narrow_canvas` in `crates/ui-egui/src/tests/taskbar.rs`. - The contextual task bar offers more next steps by selection (): an open path Join and Edit Path (the Direct Selection tool), a closed path Edit Path, a compound path Edit Path and Release Compound Path, a clipping group Edit Clipping Path and Release Clipping Mask instead of Ungroup and Isolate, an image selected with vector art Make Clipping Mask instead of Unite, point or area type Convert To Area Type or Convert To Point Type beside Create Outlines, and type on a path, for which the overview names an item to add an effect and rearrange the text, Type on a Path Options (`type.pathOptions`; its options: ) beside Create Outlines. Evidence: `crates/ui-egui/src/tests/taskbar.rs`. - While a Type tool (Type, Area Type, Type on a Path and their vertical versions) adds or edits type, the contextual task bar shows the font family menu, the font style menu, the Font Size combo and the Fill chip (); the Touch Type tool shows no bar, as the same page describes. Type selected with a selection tool shows the same three font controls beside its Create Outlines, conversion and Type on a Path items (). The controls act as the Character panel's do: on the characters a Type tool selects, else on every selected type object, with the size left blank where the sizes differ. VectorCraft's own rules: a Type tool's bar leaves out the object actions, the Stroke chip, Recolor Artwork and Lock; a Type tool shows no bar before it adds type or with other art selected; type selected with other art shows no font controls; the font controls go on a second row when one row would be wider than the canvas, and narrow to fit a canvas narrower than they are; while a Type tool edits type, the Fill chip shows the edited characters' fill ("?" where it differs). Evidence: `the_type_tools_show_font_and_fill_controls`, `the_type_tools_show_no_bar_without_type`, `selected_type_shows_font_controls_beside_its_actions`, `the_task_bars_font_size_sets_what_the_character_panel_would`, `the_type_tools_fill_chip_activates_the_fill`, `the_type_tools_bar_is_named_and_stays_on_the_canvas`, `selected_types_font_controls_take_a_second_row_on_a_narrow_canvas` and `the_type_tools_fill_chip_shows_the_edited_characters_fill` in `crates/ui-egui/src/tests/taskbar.rs`. - The help page describes the contextual task bar as one horizontal row () and gives no rule for a canvas narrower than that row. VectorCraft's own rule: the bar stays whole on the canvas. When its actions don't fit in one row, they wrap, in order, onto rows under the row with the handle, the chips and More Options; an action wider than any row, or one that would need a row past the canvas's height, is listed in More Options instead; when the chips don't fit beside the handle and More Options, they take rows after the actions; with no row to spare for the font controls, the bar leaves them out; a canvas too small for the handle and More Options shows no bar. Evidence: `a_task_bars_actions_wrap_on_a_narrow_canvas` and `a_task_bar_action_wider_than_the_canvas_goes_in_more_options` in `crates/ui-egui/src/tests/taskbar.rs`. @@ -901,6 +904,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Polish catalog (`crates/ui-egui/src/i18n/pl.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,439 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 46 Polish messages: 44 that open #7211, #7214, #7116, #7330 and #1931 translate, and 2 that no language has a row for. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). + - A status or error message whose braces are literal, such as a plug-in's `expected {"objects": [...]}` or a command's `quotes must be {double?, single?}`, is translated only when the whole message matches its row: the row writes those braces doubled and has no placeholder, so it no longer replaces a different message that starts with the same words, such as a JSON reason `expected value at line 1 column 1`, which stays in English. A row without placeholders is found at any message length (`plain_message_keys_double_their_braces`, `literal_brace_rows_match_only_their_message`). - The status bar translates the errors a script's `File` and `Folder` objects, `vc.run`, `$.evalFile`, the `$` object's properties and functions, `alert`, `confirm` and `prompt`, `vc.document` and `vc.log` raise, the limits a run can pass, and the errors of Drop Script Engine, and the line for a run that fails (a syntax error, a thrown error, a limit, a stop or an internal error, with the script's name, the line and column, the included file it is in and the files left written), in every language that translates every message. The script's name, paths, numbers, command ids and the text a script throws itself are shown as they are. A failure with no line and column, such as an error in code `eval` compiled, is shown with one colon after the script's name (`an_error_without_a_place_names_the_script_and_kind`). A message template that names a value twice matches only when both places hold the same text, so the line for a failed run is not taken for the message inside it (`a_repeated_placeholder_matches_the_same_text`). The untranslated-strings report also lists the messages the script host's `fail`, `value` and `over` helpers take and the errors the script interpreter throws, and a constant's text comes from the file or crate that declares it, so the Scripts menu's headers are not listed as messages (`script_errors_are_translated`, `script_host_errors_are_translated`, `message_scan_reads_script_interpreter_errors`, `message_scan_reads_constants_of_the_file_and_its_crate`, `message_scan_skips_test_modules_with_any_test_cfg`). - The Traditional Chinese catalog (`crates/ui-egui/src/i18n/zh-hant.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,438 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 40 Traditional Chinese messages: open #7211, #7116 and #7330 add the rows for 38 of them, and the other 2 have no row in any catalog. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). @@ -1116,6 +1120,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` refuse a `subpath` that is not a whole number of 0 or more, and `path.split` refuses such an `anchor` or `segment`; without a `subpath` they edit subpath 0. A value given that is not a whole number, such as `"1"` or `1.5`, is refused with `` `subpath` must be a whole number ``, naming its key, as are a required index of the Appearance panel and Smart Pen commands, `sourceDocument` of `edit.dropSelection` and `index` and `to` of `document.activate` and `document.move`; a required one left out or null is reported as missing (`tests::params::a_malformed_required_integer_is_not_reported_as_missing`). `path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size. `path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`. + `path.setHandle`, `path.moveAnchor`, `text.setPathHandle` (`anchor`), `path.insertAnchor` (`segment`), `artboard.reorder`, `library.moveGroup`, `graphicStyle.move` (`to`), `path.appendAnchor`, `path.close`, `path.reverse` (`subpath`) and the ruler origin commands (`artboard`) refuse a value that is not a whole number of 0 or more, such as `"3"` or `2.5`, with `` `anchor` must be a whole number ``, naming the key, instead of reporting it as missing or reading it as the default subpath or artboard; a null `artboard` is artboard 0 (`tests::params::a_malformed_index_is_not_missing_or_its_default`). `path.close` and `path.setHandle` (`independent`), `path.moveAnchors` (`patterns`), the Pencil (`closed`, `keepSelected`, `fill`), Curvature (`closed` and a point's `corner`), `path.curvatureEdit` (`corner`), `path.reshapeSegment` (`perpendicular`) and the Blob Brush (`keepSelected`, `mergeOnlySelection`, `merge`) refuse a value other than true, false or null, such as "true", and leave the document unchanged. Direct Selection's marquee selects the frame anchors of area type that lie inside it, and a click on a frame anchor selects that one; Shift toggles them. The selected frame anchors show filled. A drag of one of them moves all of them, the arrow keys nudge them, and the type reflows at its size (`text.reshapeArea`, which takes `items` to move the anchors of several frames in one step), as [Create text](http://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) and [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describe. When the selection also holds other objects, path anchors or ruler guides, or when another change to the document or the selection follows the pick, the type is selected whole. A press on a selected object's centre point (Attributes panel › Show Center) selects the object whole, and a drag from it moves the object as one Move, held by that point (#4891; test `the_centre_point_selects_and_moves_the_object_whole`). @@ -1128,7 +1133,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo; a kept gradient takes the same place on each mark's own box. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. What Global Edit gives a mark from another match (in an isolated graph) is kept on the graph the same way, and so is the object opacity, with its blend mode, that Randomize Opacity and Sort Objects change on marks. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. @@ -1260,6 +1265,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A 3D effect on one fill or stroke ([the help page](https://helpx.adobe.com/illustrator/using/effects.html)) leaves the object's other fills and strokes drawn with the path's or compound path's fill rule, so an even-odd compound path keeps its holes in them; this holds for Extrude & Bevel, Inflate, Rotate and Revolve, and for the Classic Extrude & Bevel, Rotate and Revolve. Tests: `solids::tests::a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole`, `revolve::tests::a_fill_without_the_revolve_keeps_the_even_odd_hole`. - With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`. - When the fill and strokes are at full opacity with Normal blending, under a stroke that leaves parts of the fill's edge uncovered (dashed, with a width profile or a brush, on an open subpath, or in a paint that may show through), Extrude & Bevel (both kinds) and Inflate cut each stroke face that passes through a face of the fill's solid, or of another of the shape's strokes, where their depths meet, and draw the faces of the shape's fill and strokes that overlap in the order of their depths where they overlap, instead of by the depths of their centers. Inflate orders a plain stroke the same way, since its fill can rise in front of the stroke. The fill no longer paints over a nearer dash, and a dash's end no longer covers the fill wall in front of it; a fill wall in a gap between dashes stays visible. The same order applies inside a group member's own opacity mask or blending mode, to a gradient with transparent stops under a colored light with graphics on its caps, and to Inflate in gradients under a colored light. Tests: `solids::tests::fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap`, `solids::tests::local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order`, `solids::tests::a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet`, `solids::tests::a_masked_members_fill_and_stroke_faces_go_in_one_order`, `solids::tests::a_colored_inflate_in_gradients_draws_its_faces_in_one_order`, `solids::tests::a_depth_cycle_keeps_the_cap_last`, `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`. + - Revolve and Revolve (Classic) draw two overlapping faces of a surface in the order of their depths where they overlap, instead of by the depths of their centers, on one object and across a group's members, so a tilted profile with long edges or a profile of several contours no longer draws a nearer face under a farther one. A surface whose faces were already in the right order keeps it. Tests: `solids::tests_revolve_order::a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last`, `solids::tests_revolve_order::a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last`, `solids::tests_revolve_order::a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last`, `solids::tests_revolve_order::a_surface_already_in_order_keeps_it`, `solids::tests_revolve_order::surface_order_puts_the_nearer_face_where_they_overlap_last`, `tests::a_revolved_face_measures_its_depth_on_its_plane`. - Under a colored light, an Inflate fill and strokes in solid colors, opaque with Normal blending, also draw their faces in that one order, inside a group member's own opacity mask too, instead of each solid as one composite with the stroke's faces over every fill face. Each face keeps the composite's paint, light ramps and seam width; a lone solid, the solids of an object with a translucent or blended fill or stroke, and such solids holding more than 30,720 faces together (VectorCraft's own limit) are still drawn as one composite each. Tests: `solids::tests::colored_solid_fills_and_strokes_share_face_depth_order`, `solids::tests::a_colored_solid_face_on_its_own_keeps_the_composites_colors`, `solids::tests::colored_solid_inflate_batches_channels_and_clips_once_per_surface`, `solids::tests::colored_solids_past_the_face_limit_draw_as_composites`. - Without a twist, a tapered Extrude & Bevel wall (and each bevel band) is one flat face; only a twist cuts walls into slices, so no seam between slices paints a light tick or a light line on a tapered, untwisted wall (#7019). Test: `solid::tests::a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced`. - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). @@ -1268,6 +1274,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The solids of one Extrude & Bevel (both kinds) or Inflate effect on one object, its fill's, its strokes' and a group's members' together, keep up to 163,840 faces (VectorCraft's own limit). Each solid is built in turn; once their faces pass the limit, no later solid is built, the object draws its source art, and applying or editing the effect, Export 3D Object and Expand as Wireframes report that the 3D surface is too complex. Tests `solids::tests::an_objects_solids_count_their_faces_together`, `a_groups_solids_together_past_the_limit_draw_the_source`, `a_fill_and_strokes_together_past_the_limit_draw_the_source`. - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset. An edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid stay straight (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `a_beveled_cap_and_its_walls_share_their_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). + - Export 3D Object builds each Extrude & Bevel (both kinds) and Inflate solid once: the same build gives the faces that count toward the object's limit and the faces of its mesh, and the meshes match those of a separate build bit for bit. Tests `mesh::tests::a_meshed_solid_is_built_once`, `an_extruded_solid_and_its_mesh_match_the_two_built_apart`, `inflated_solids_and_their_meshes_match_the_two_built_apart`, `solids::tests_mesh::one_build_exports_the_meshes_built_apart`, `a_stroke_past_the_limits_is_left_out_of_the_export`. - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Revolve takes graphics on its swept side, shaded with the object's light ([the archived reference, page 185](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185)). By VectorCraft's own rule a graphic wraps around the axis at its own size at every distance from it, narrows toward the silhouette, follows the profile along its length and is hidden where the side faces away (`threeD.mapGraphic`; `revolve::tests_graphics`, `tests::side_faces_lie_on_the_unrolled_surface_and_caps_do_not`, `map_graphic_puts_a_graphic_on_the_side_of_a_revolve`). - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). @@ -1315,7 +1322,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Mouse wheel over the font family field (#5253):** In the Character and Properties panels and the Control bar, turning the mouse wheel over the closed font family field applies the next family to the selected type (the previous one when turned up), keeping the style when the family has it, else its closest. Each notch is one undo step; leaving the field or Escape keeps the family, and Undo takes it back. The steps use the font menu's order at the first turn and keep it until the pointer leaves the field or the text selection changes, so recent-font updates do not reshuffle later turns. That order follows the font filters every font menu shares (one chosen in the Glyphs panel's menu or a dialog's applies too) but ignores its search text, puts recent families first, and stops at the first and last family. A turn with a modifier key is left to the panel and the canvas. Behavior reference: the [Illustrator CS6 reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf#page=10) describes editing hovered dropdown controls with the mouse wheel; [Find and apply fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-apply-fonts.html) names the family field's places. Applying it to the font field, the order, the ends and the undo steps are VectorCraft's own rules. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/tests/font_wheel.rs`. - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). Each reference shows an icon of the height it measures before its name, in the open list and in the closed field (#6304): a frame around a letter for Em Box, a bar from the baseline to the top of a capital for Cap Height and of a lowercase x for x-Height, and a frame inset in the em square for ICF Box. With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). - The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line gets the room of the narrower part of the frame, or of the span beside a wrap object, that it reaches. Such a paragraph is broken with the Single-line Composer when the frame has no room for all of it or when the search passes VectorCraft's own limits. Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/set-hyphenation-and-line-breaks.html). + The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line gets the room of the narrower part of the frame, or of the span beside a wrap object, that it reaches. When the frame cannot hold all of such a paragraph, the lines it holds are still composed with the whole paragraph, the lines past the frame's end measured as wide as the last line the frame holds (VectorCraft's own rule); they overflow or go on in the next threaded frame, which composes them again. Such a paragraph is broken with the Single-line Composer when the frame has no room for its first line or when the search passes VectorCraft's own limits. Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/set-hyphenation-and-line-breaks.html), and for text that flows on between threaded objects, [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html). A text wrap object's Offset may be negative as well as positive ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=307)). A negative Offset (to -1000 pt) shrinks the wrap object by that distance, measured from each edge itself and from the edges of its holes, so lines beside it, above it and below it run that far into it, and an object narrower than twice the distance no longer moves text aside. With Invert Wrap, a negative Offset keeps the text that far inside the object. These are VectorCraft's own rules for the shape of the shrunk object. Text Wrap Options, `object.textWrap.make` and `object.textWrap.options` and the MCP `text_wrap` tool take the negative value, and layout uses it (`crates/text/src/layout/region.rs` tests, `cmd::textwrap::tests::text_wrap_options_negative_offset_reflows_and_round_trips`). In area type of a shape other than a rectangle, each line's whole band, ascent to descent, stays inside the frame: where an edge pinches in within the band, the line ends at the narrowest point, and a line runs beside a hole that starts and ends within its band, not across it. In area type of any shape, a line also stays clear of a text wrap object that reaches into any part of its band, and inside an Invert Wrap object over its whole band (`crates/text/src/layout/region.rs` tests). In area type whose frame has several contours, a line runs across an edge two contours share only when their edges lie exactly on one line, so a gap between contours keeps lines from crossing it, even a gap one unit in the last place wide near the edge of the canvas; an edge divided by an anchor exactly on it still lets lines cross (`layout::region::separate_slanted_contours_keep_a_four_ulp_gap_near_canvas_limit`, `shared_slanted_edge_still_joins_near_canvas_limit`, `area_type_sets_no_line_across_a_four_ulp_gap_between_slanted_contours`). @@ -1397,6 +1404,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved. Character panel › Insert Aki Left and Insert Aki Right (with the East Asian options; `text.setFormat` or `text.setRangeStyle {akiLeft, akiRight}`, thousandths of an em from 0 to 1000, 0 by default) add space before and after each selected character, measured as tracking is, and kept as set through justification ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); before and after follow the reading order in vertical and right-to-left type, character styles carry both values (Character Style Options › Advanced Character Formats edits them), and a file holding a value outside the range opens with the nearer end. + Character panel › Tsume (with the East Asian options; `text.setFormat` or `text.setRangeStyle {tsume}`) narrows the space around each selected character ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); by VectorCraft's own rule the value is a percentage from 0 to 100, 0 by default, of the blank space on each side of the character's ink that horizontal type takes away without changing the glyph's shape, with tracking, kerning and Insert Aki added after it. Character styles carry it (Character Style Options › Advanced Character Formats edits it), and a file holding a value outside the range opens with the nearer end. The Character panel's All Caps, Underline and Strikethrough buttons and its menu's All Caps, Underline, Strikethrough, Proportional Metrics, Tate-chu-yoko and No Break show off while only some of the selected characters have the attribute, and choosing one then turns it on for all of them, as `type.underline` (Cmd+Shift+U) does for underlining; the Character Alignment and Digits submenus check no item while the selected characters differ. `text.setFormat` refuses a value that is not true or false for its on/off attributes, as `text.setRangeStyle` does. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286). 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. @@ -1570,6 +1578,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page describes effects on one fill or stroke, and the help page describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. + A raster effect (Drop Shadow, Outer Glow, Gaussian Blur and the other effects that paint pixels) on a symbol instance's topmost fill draws on each filled shape of the symbol's art, and one on its topmost stroke draws on each stroked shape, after any geometry effects listed before it, under the same rules as geometry effects: only a visible fill or stroke with an opacity above 0 counts, a group's own fills and strokes in the art take the effect, and an instance inside the art passes it on. Each shape gets its own shadow or glow, which is VectorCraft's own rule. The canvas, Use Preview Bounds and the drawn bounds, SVG and PDF export draw it, and Object › Expand Appearance is enabled for such an instance and makes the effect an image at the document's raster effects resolution, in one undo step (engine `expand::a_symbol_instance_fill_shadow_shadows_its_filled_art`, `a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art`, `a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it`, `a_nested_symbol_instance_takes_the_outer_fill_shadow`, `export_draws_a_symbol_instance_fill_shadow`). Existing documents with a raster effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents. @@ -1687,7 +1696,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, an object in a pattern's art or a brush's art with an art style VectorCraft reads opens as itself with its effects live, as an object on a layer does, and an object in a pattern's art keeps the id of a graphic style in the file's list (`crates/eps/src/import/ai/tests.rs`). A graph in a `.ai` whose records have the shape and values the published version 7 specification gives ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), printed pp. 109–112 and 146–158) opens as a live grouped column, stacked column, line or area graph with its saved art, table, axes and settings, and Graph Data edits it ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html)); other graphs keep their art as plain objects with a note, and saving a graph to `.ai` warns that its data and settings aren't written (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/graph/tests.rs`). Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). - A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in None or Tints keeps that colorization, and one in the newer form also keeps its fixed width, Flip Along and Flip Across, and Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with its guides ([Art brush options](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf), printed page 193). Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form that bends art of solid-filled paths with those options, open as the path with the brush on its stroke, except paths whose brush scales its art proportionately. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.rs`, `crates/eps/src/import/tests_native.rs`). @@ -1705,6 +1714,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, on by default; `.ai` saves leave it off) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard, so readers that ignore `UserUnit` show them at full size. Export As PDF with Use Artboards off also opens the Save PDF dialog, without its Artboards section, and writes one page of the visible art's bounds with the dialog's options (`document.exportPdf {useArtboards: false}`; `crates/ui-egui/src/dialogs/tests_export.rs`, `crates/engine/src/cmd/fileio/tests_pdf.rs`). + `document.exportSelection` run without params (a shortcut) takes the same route after its save panel: a picked PDF opens the Save PDF dialog without its Artboards section, and SVG, the raster formats, EPS, DXF, text and exporter plug-ins with parameters open their options, whose OK writes the selection alone, cropped to it, with the chosen options (`crates/ui-egui/src/dialogs/tests_export_selection.rs`). A `.ai` export with Use Artboards off writes one page of the visible art's bounds without the native document or the editing copy for other apps, with a warning, so the file does not reopen with one artboard in place of the document's; without PDF content it is refused (`crates/engine/src/cmd/fileio/tests_pdfedit.rs`). PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. In a PDF without a standard, the warning for a page longer than 14,400 pt written with a `UserUnit` names "Write pages longer than 200 in at full size in points" (`crates/engine/src/cmd/fileio/tests_pdf.rs`). @@ -1764,7 +1774,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets. - Print's Copies per Sheet (`copiesPerSheet`, `gutter`; off by default) prints as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, as one grid placed like a single copy; the preview shows them, and an artboard that does not fit prints one copy per sheet with a warning (#1283). - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - - On X11, quitting while the Paste menu's background check waits on a clipboard owner that never answers waits up to 27 s for that check to drop its clipboard handle, so the app's own handle is the last one dropped when the check drops its handle within that wait, and the app quits after the wait when it has not; the window is hidden before that wait, so an X11 window closes at once (`crates/ui-egui/src/tests/sysclip_probe.rs`, `apps/vectorcraft/src/clipboard.rs`, `apps/vectorcraft/src/tests.rs`). + - On X11, quitting while the Paste menu's background check waits on a clipboard owner that never answers waits up to 27 s for that check to drop its clipboard handle, so the app's own handle is the last one dropped when the check drops its handle within that wait, and the app quits after the wait when it has not; the window is hidden before that wait, so an X11 window closes at once (`crates/ui-egui/src/tests/sysclip_probe.rs`, `apps/vectorcraft/src/clipboard.rs`, `apps/vectorcraft/src/quit_windows.rs`). + - On Linux, quitting takes every window off screen before that wait: a native Wayland main window is unmapped (its surface gets no buffer) and stays open with the connection the Wayland clipboard uses, and the X11 windows of floating panels and documents are unmapped with the hidden main window (`apps/vectorcraft/src/quit_windows.rs`). - File › Place reads RTF files as area type with their formatting: each run's font, size, bold and italic, underline, strikethrough, caps, superscript or subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction. Table cells are separated by tabs; hidden text, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). - File › Place reads Word `.docx` documents as area type with the same formatting, taken from the document defaults, the paragraph and character styles (their toggle properties reversing each other) and direct formatting, the theme's fonts included. Table rows are paragraphs with their cells separated by tabs; hidden and deleted text, field instructions, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). - A Word paragraph justified with a low, medium or high kashida length places justified with Insert Kashidas set to Short, Medium or Long (`file.place`, `crates/engine/src/tests/textimport.rs`).