diff --git a/ROADMAP.md b/ROADMAP.md index fe325ff849..fe038ec97a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,6 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. +- 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index ffa1d2a5e8..2da5f0083d 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -58,7 +58,10 @@ pollster = "1" # cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which # src/caps_lock.rs uses to read whether Caps Lock is on. Win32_System_Registry has `RegGetValueW`, # which src/host_services.rs uses to read the path PhotoCraft registers under App Paths. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# Win32_Devices_Display has `QueryDisplayConfig`, Win32_UI_ColorSystem the display's ICC profile and +# Win32_System_LibraryLoader `GetProcAddress`, which src/display_profile.rs uses to read the +# display's color profile for the canvas. +windows = { version = "0.62", features = ["Win32_Devices_Display", "Win32_Foundation", "Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_ColorSystem", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } # The renderer's worker count follows those cores (src/windows_cores.rs). vectorcraft-render = { workspace = true } @@ -93,7 +96,8 @@ winit = { version = "0.30", default-features = false, features = ["wayland-csd-a # A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a # second X connection, x11rb's pure-Rust `RustConnection`. MIT OR Apache-2.0, already in the build # through winit with the `xinput` feature. XKB reads Caps Lock's locked modifier state (src/caps_lock.rs). -x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] } +# RandR reads a display's `_ICC_PROFILE` for the canvas (src/display_profile.rs). +x11rb = { version = "0.13", default-features = false, features = ["randr", "xinput", "xkb"] } # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs), # so Automatic draws on that one, and copies and pastes text, SVG and PNG through the compositor's diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 4827772444..126b671877 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -219,6 +219,9 @@ impl App { self.app.services.dock_monitors = window::dock_monitors(w, zoom); self.monitors_listed = Some((now, zoom)); } + if let Some(w) = &self.winit_window { + self.display_profile.tick(w, now, self.app.session.prefs.monitor_profile_display, &self.app.display_profile, ctx); + } self.app.tick(ctx); if self.pending_maximize { if let Some(w) = &self.winit_window { diff --git a/apps/vectorcraft/src/display_profile.rs b/apps/vectorcraft/src/display_profile.rs new file mode 100644 index 0000000000..2be6e27aa5 --- /dev/null +++ b/apps/vectorcraft/src/display_profile.rs @@ -0,0 +1,629 @@ +//! Reads the ICC profile of the display that shows most of the main window, for Preferences › +//! Performance › Use Monitor Profile for Canvas, and hands it to [`DisplayProfile::report`]. +//! +//! - Windows: the display's default profile for its standard color mode. A display with Advanced +//! Color on (HDR or Automatic Color Management) is converted by Windows, and nothing is read. +//! - X11: colord's profile for the RandR output, then the output's `_ICC_PROFILE` property, then +//! the screen's `_ICC_PROFILE` when only one output is active. +//! - Wayland, XWayland included: the compositor gives applications no display profile, and the +//! canvas stays sRGB. +//! - macOS: the window's layer is tagged sRGB and macOS converts it (`mac_color_space`). +//! +//! While the preference is on, a background thread reads the display every [`EVERY`] seconds, +//! and at once when the window moves to another display. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use vectorcraft_ui_egui::display_profile::{DisplayProfile, Found, Reading}; + +/// Seconds between two readings of the same display. +const EVERY: f64 = 10.0; +/// Seconds between two checks of which display shows the window. +const LOOK_EVERY: f64 = 1.0; + +/// Starts the readings from the main window's frames. +#[derive(Default)] +pub(crate) struct Reader { + /// When the last reading started, and the display it was for. + last: Option<(f64, Option)>, + /// When the window's display was last looked up. + looked: Option, + /// A reading is running. + busy: Arc, +} + +/// Clears [`Reader::busy`] when the reading thread ends, also by a panic. +struct Done(Arc); + +impl Drop for Done { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl Reader { + /// Starts a reading when one is due and none is running. `now` is the frame's time in seconds. + pub(crate) fn tick(&mut self, window: &winit::window::Window, now: f64, enabled: bool, profile: &DisplayProfile, ctx: &egui::Context) { + if !enabled || self.busy.load(Ordering::Acquire) || self.looked.is_some_and(|at| (at..at + LOOK_EVERY).contains(&now)) { + return; + } + self.looked = Some(now); + let display = window.current_monitor().and_then(|m| m.name()); + if !due(self.last.as_ref(), now, display.as_deref()) { + return; + } + self.last = Some((now, display.clone())); + profile.pending(); + self.busy.store(true, Ordering::Release); + let done = Done(self.busy.clone()); + let (profile, ctx) = (profile.clone(), ctx.clone()); + let spawned = std::thread::Builder::new().name("display profile".into()).spawn(move || { + let _done = done; + if profile.report(read(display)) { + ctx.request_repaint(); + } + }); + if let Err(e) = spawned { + log::warn!("couldn't start reading the display profile: {e}"); + } + } +} + +/// Whether a reading of `display` is due at `now`, after the last one (`last`): none yet, another +/// display, [`EVERY`] seconds later, or a clock that went back. +fn due(last: Option<&(f64, Option)>, now: f64, display: Option<&str>) -> bool { + last.is_none_or(|(at, shown)| shown.as_deref() != display || !(*at..at + EVERY).contains(&now)) +} + +#[cfg(windows)] +fn read(display: Option) -> Reading { + let found = win::read(display.as_deref()); + Reading { display, found } +} + +#[cfg(target_os = "linux")] +fn read(display: Option) -> Reading { + let found = if wayland_session(|k| std::env::var_os(k)) { + Found::NoProfile("a Wayland session doesn't give applications the display's profile".into()) + } else { + x11::read(display.as_deref()) + }; + Reading { display, found } +} + +#[cfg(all(target_os = "macos", feature = "wgpu"))] +fn read(display: Option) -> Reading { + Reading { display, found: Found::System("macOS converts the window to the display's profile".into()) } +} + +#[cfg(not(any(windows, target_os = "linux", all(target_os = "macos", feature = "wgpu"))))] +fn read(display: Option) -> Reading { + Reading { display, found: Found::NoProfile("this build doesn't read display profiles on this system".into()) } +} + +/// Whether the app runs in a Wayland session, read from the environment `var` gives. XWayland +/// windows count: the variables stay set for them. +#[cfg(any(target_os = "linux", test))] +fn wayland_session(var: impl Fn(&str) -> Option) -> bool { + ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"].iter().any(|k| var(k).is_some_and(|v| !v.is_empty())) +} + +/// The X11 reading from colord's answer `colord` (the profile file it names, read) and the X11 +/// properties `properties`: colord's profile when its file can be read, otherwise the +/// properties' profile. When neither has one, the reason colord gave is kept. +#[cfg(any(target_os = "linux", test))] +fn x11_choice(colord: Option, String>>, properties: impl FnOnce() -> Result, String>) -> Found { + let colord_reason = match colord { + Some(Ok(Some(found @ Found::Profile { .. }))) => return found, + Some(Ok(Some(Found::NoProfile(e) | Found::System(e)))) | Some(Err(e)) => Some(e), + Some(Ok(None)) | None => None, + }; + match properties() { + Ok(Some(found)) => found, + Ok(None) => Found::NoProfile(match colord_reason { + Some(e) => format!("no profile is set for this display ({e})"), + None => "no profile is set for this display".into(), + }), + Err(e) => Found::NoProfile(e), + } +} + +/// `found` when it is not a profile or the profile can be used, otherwise why it can't, so the +/// next source is tried. +#[cfg(any(target_os = "linux", test))] +fn usable(found: Found) -> Found { + match found { + Found::Profile { icc, from } => match vectorcraft_ui_egui::display_profile::check_profile(&icc) { + Ok(()) => Found::Profile { icc, from }, + Err(e) => Found::NoProfile(format!("{from}: {e}")), + }, + other => other, + } +} + +/// The ICC file at `path`, refused before it is read when it is larger than [`MAX_PROFILE_BYTES`]. +/// +/// [`MAX_PROFILE_BYTES`]: vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES +#[cfg(any(windows, target_os = "linux", test))] +fn read_icc(path: &std::path::Path) -> Result, String> { + use std::io::Read as _; + let max = vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES; + let too_large = || format!("{} is larger than {} MiB", path.display(), max >> 20); + let file = std::fs::File::open(path).map_err(|e| format!("couldn't open {}: {e}", path.display()))?; + let len = file.metadata().map_err(|e| format!("couldn't read {}: {e}", path.display()))?.len(); + if len > max as u64 { + return Err(too_large()); + } + let mut bytes = Vec::with_capacity(len as usize); + file.take(max as u64 + 1).read_to_end(&mut bytes).map_err(|e| format!("couldn't read {}: {e}", path.display()))?; + if bytes.len() > max { Err(too_large()) } else { Ok(bytes) } +} + +/// The file a profile `name` stands for: `name` itself when it is a path, otherwise the file of +/// that name in the system's color folder `dir`. +#[cfg(any(windows, test))] +fn profile_path(dir: Option<&std::path::Path>, name: &str) -> std::path::PathBuf { + match dir { + Some(dir) if !name.contains(['\\', '/']) => dir.join(name), + _ => std::path::PathBuf::from(name), + } +} + +/// `path`'s profile, or why there is none. +#[cfg(any(windows, target_os = "linux"))] +fn profile_file(path: &std::path::Path) -> Found { + let from = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()); + match read_icc(path) { + Ok(icc) => Found::Profile { icc, from }, + Err(e) => Found::NoProfile(e), + } +} + +#[cfg(windows)] +#[allow(unsafe_code)] +mod win { + use std::path::PathBuf; + use std::sync::OnceLock; + + use vectorcraft_ui_egui::display_profile::Found; + use windows::Win32::Devices::Display::{ + DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_HEADER, + DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_SOURCE_DEVICE_NAME, + DisplayConfigGetDeviceInfo, GetDisplayConfigBufferSizes, QDC_ONLY_ACTIVE_PATHS, QueryDisplayConfig, + }; + use windows::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_SUCCESS, HLOCAL, LUID, LocalFree}; + use windows::Win32::Graphics::Gdi::{CreateDCW, DeleteDC}; + use windows::Win32::System::LibraryLoader::{GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW}; + use windows::Win32::UI::ColorSystem::{ + CPST_STANDARD_DISPLAY_COLOR_MODE, CPT_ICC, GetColorDirectoryW, GetICMProfileW, WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER, + }; + use windows::core::{HRESULT, PCWSTR, PWSTR, s, w}; + + /// `ColorProfileGetDisplayDefault` and `ColorProfileGetDisplayUserScope` (mscms.dll), which + /// older Windows versions don't have. + type GetDefault = unsafe extern "system" fn(i32, LUID, u32, i32, i32, *mut PWSTR) -> HRESULT; + type GetScope = unsafe extern "system" fn(LUID, u32, *mut i32) -> HRESULT; + + /// The display path that shows a GDI device. + struct Path { + source: u32, + target_adapter: LUID, + target: u32, + } + + pub(super) fn read(display: Option<&str>) -> Found { + let Some(device) = display else { return Found::NoProfile("Windows didn't name the window's display".into()) }; + let path = match find_path(device) { + Ok(path) => path, + Err(e) => return Found::NoProfile(e), + }; + if path.as_ref().is_some_and(advanced_color) { + return Found::System("Windows manages this display's colors (Advanced Color)".into()); + } + let name = match path.as_ref().and_then(default_profile) { + Some(Ok(name)) => name, + Some(Err(e)) => return Found::NoProfile(e), + // Older Windows, or a display without an active path: GDI's profile for the device. + None => match icm_profile(device) { + Ok(name) => name, + Err(e) => return Found::NoProfile(e), + }, + }; + match name { + Some(name) => super::profile_file(&super::profile_path(color_directory().as_deref(), &name)), + None => Found::NoProfile("no color profile is set for this display".into()), + } + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain([0]).collect() + } + + fn until_nul(w: &[u16]) -> String { + String::from_utf16_lossy(&w[..w.iter().position(|&c| c == 0).unwrap_or(w.len())]) + } + + /// The active display paths. + fn paths() -> Result, String> { + for _ in 0..4 { + let (mut paths_len, mut modes_len) = (0u32, 0u32); + // SAFETY: both pointers are to live `u32`s. + let e = unsafe { GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut paths_len, &mut modes_len) }; + if e != ERROR_SUCCESS { + return Err(format!("Windows couldn't list the displays (error {})", e.0)); + } + let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); paths_len as usize]; + let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); modes_len as usize]; + // SAFETY: the arrays hold as many elements as the lengths passed with them. + let e = + unsafe { QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &mut paths_len, paths.as_mut_ptr(), &mut modes_len, modes.as_mut_ptr(), None) }; + if e == ERROR_INSUFFICIENT_BUFFER { + // The displays changed between the two calls. + continue; + } + if e != ERROR_SUCCESS { + return Err(format!("Windows couldn't list the displays (error {})", e.0)); + } + paths.truncate(paths_len as usize); + return Ok(paths); + } + Err("the displays kept changing while Windows listed them".into()) + } + + /// The path whose source is the GDI device `device` (`\\.\DISPLAY1`), if one is active. + fn find_path(device: &str) -> Result, String> { + for p in paths()? { + let mut name = DISPLAYCONFIG_SOURCE_DEVICE_NAME { + header: DISPLAYCONFIG_DEVICE_INFO_HEADER { + r#type: DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, + size: size_of::() as u32, + adapterId: p.sourceInfo.adapterId, + id: p.sourceInfo.id, + }, + ..Default::default() + }; + // SAFETY: the header starts a `DISPLAYCONFIG_SOURCE_DEVICE_NAME` of the size it states. + if unsafe { DisplayConfigGetDeviceInfo(&mut name.header) } == 0 && until_nul(&name.viewGdiDeviceName).eq_ignore_ascii_case(device) { + return Ok(Some(Path { source: p.sourceInfo.id, target_adapter: p.targetInfo.adapterId, target: p.targetInfo.id })); + } + } + Ok(None) + } + + /// Whether Windows color-manages the path's display itself: Advanced Color is on (HDR), or + /// wide color is enforced (Automatic Color Management on an SDR display). + fn advanced_color(path: &Path) -> bool { + let mut info = DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO { + header: DISPLAYCONFIG_DEVICE_INFO_HEADER { + r#type: DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, + size: size_of::() as u32, + adapterId: path.target_adapter, + id: path.target, + }, + ..Default::default() + }; + // SAFETY: the header starts a `DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO` of the size it states. + if unsafe { DisplayConfigGetDeviceInfo(&mut info.header) } != 0 { + return false; + } + // SAFETY: every bit pattern is a valid `u32`. + let bits = unsafe { info.Anonymous.value }; + // advancedColorEnabled is bit 1 and wideColorEnforced bit 2. + bits & 0b110 != 0 + } + + fn api() -> Option<(GetDefault, GetScope)> { + static API: OnceLock> = OnceLock::new(); + *API.get_or_init(|| { + // SAFETY: mscms.dll is loaded from System32 only, and stays loaded for the process. + let module = unsafe { LoadLibraryExW(w!("mscms.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) }.ok()?; + // SAFETY: the names are NUL-terminated, and the module is loaded. + let (default, scope) = unsafe { + (GetProcAddress(module, s!("ColorProfileGetDisplayDefault"))?, GetProcAddress(module, s!("ColorProfileGetDisplayUserScope"))?) + }; + // SAFETY: both exports have these signatures (icm.h). + unsafe { + Some(( + std::mem::transmute:: isize, GetDefault>(default), + std::mem::transmute:: isize, GetScope>(scope), + )) + } + }) + } + + /// The display's default profile name for its standard color mode, `None` without the API. + fn default_profile(path: &Path) -> Option, String>> { + let (get_default, get_scope) = api()?; + let mut scope = WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER.0; + // SAFETY: `scope` is a live `i32`. + if unsafe { get_scope(path.target_adapter, path.source, &mut scope) }.is_err() { + scope = WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER.0; + } + let mut name = PWSTR::null(); + // SAFETY: `name` is a live pointer the function fills with a string it allocates. + let hr = unsafe { get_default(scope, path.target_adapter, path.source, CPT_ICC.0, CPST_STANDARD_DISPLAY_COLOR_MODE.0, &mut name) }; + if hr.is_err() { + return Some(Err(format!("Windows couldn't give the display's profile (error {:#010x})", hr.0))); + } + if name.is_null() { + return Some(Ok(None)); + } + // SAFETY: `name` is a NUL-terminated string from the call above, freed once with LocalFree. + let text = unsafe { name.to_string() }; + // SAFETY: as above. + let _ = unsafe { LocalFree(Some(HLOCAL(name.0.cast()))) }; + Some(text.map(|t| Some(t).filter(|t| !t.is_empty())).map_err(|e| format!("the display's profile name isn't readable: {e}"))) + } + + /// GDI's profile file for the display device `device`. + fn icm_profile(device: &str) -> Result, String> { + let device = wide(device); + // SAFETY: both strings are NUL-terminated and outlive the call. + let dc = unsafe { CreateDCW(w!("DISPLAY"), PCWSTR(device.as_ptr()), PCWSTR::null(), None) }; + if dc.is_invalid() { + return Err("Windows couldn't open the display".into()); + } + let mut len = 0u32; + // SAFETY: a null buffer asks for the length only. + let _ = unsafe { GetICMProfileW(dc, &mut len, None) }; + let mut buf = vec![0u16; len.clamp(1, 32 * 1024) as usize]; + len = buf.len() as u32; + // SAFETY: `buf` holds `len` characters. + let ok = unsafe { GetICMProfileW(dc, &mut len, Some(PWSTR(buf.as_mut_ptr()))) }.as_bool(); + // SAFETY: `dc` came from CreateDCW above. + let _ = unsafe { DeleteDC(dc) }; + Ok(ok.then(|| until_nul(&buf)).filter(|n| !n.is_empty())) + } + + /// The system's color folder (`…\spool\drivers\color`). + fn color_directory() -> Option { + let mut bytes = 0u32; + // SAFETY: a null buffer asks for the size only, in bytes. + let _ = unsafe { GetColorDirectoryW(PCWSTR::null(), None, &mut bytes) }; + let mut buf = vec![0u16; (bytes as usize / 2).clamp(1, 32 * 1024)]; + bytes = (buf.len() * 2) as u32; + // SAFETY: `buf` holds `bytes` bytes. + unsafe { GetColorDirectoryW(PCWSTR::null(), Some(PWSTR(buf.as_mut_ptr())), &mut bytes) }.as_bool().then(|| PathBuf::from(until_nul(&buf))) + } + + #[cfg(test)] + mod tests { + use windows::Win32::Graphics::Gdi::{DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISPLAY_DEVICEW, EnumDisplayDevicesW}; + + use super::*; + + /// The GDI names (`\\.\DISPLAY1`) of the displays attached to the desktop. + fn attached_displays() -> Vec { + let mut names = Vec::new(); + for i in 0.. { + let mut device = DISPLAY_DEVICEW { cb: size_of::() as u32, ..Default::default() }; + // SAFETY: `device` is a live `DISPLAY_DEVICEW` with `cb` set to its size. + if !unsafe { EnumDisplayDevicesW(PCWSTR::null(), i, &mut device, 0) }.as_bool() { + break; + } + if device.StateFlags.contains(DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) { + names.push(until_nul(&device.DeviceName)); + } + } + names + } + + #[test] + fn windows_reads_each_attached_display_and_refuses_an_unknown_one() { + assert_eq!(read(None), Found::NoProfile("Windows didn't name the window's display".into())); + assert!(color_directory().is_none_or(|dir| dir.is_absolute())); + match find_path(r"\\.\DISPLAY999") { + Ok(path) => { + assert!(path.is_none()); + assert_eq!(read(Some(r"\\.\DISPLAY999")), Found::NoProfile("Windows couldn't open the display".into())); + } + // A session without displays (a service): the reading gives Windows' error. + Err(e) => { + eprintln!("the displays couldn't be listed: {e}"); + assert_eq!(read(Some(r"\\.\DISPLAY999")), Found::NoProfile(e)); + return; + } + } + for name in attached_displays() { + // A display without an active path is read through GDI (`icm_profile`). + let path = find_path(&name).unwrap(); + // winit names the display in GDI's spelling; the match ignores letter case. + let lower = find_path(&name.to_lowercase()).unwrap(); + assert_eq!(lower.map(|p| (p.source, p.target)), path.as_ref().map(|p| (p.source, p.target)), "{name} in lowercase"); + match read(Some(&name)) { + Found::Profile { icc, from } => { + eprintln!("{name}: profile {from}, {} bytes", icc.len()); + assert!(!icc.is_empty() && !from.is_empty()); + assert!(!path.as_ref().is_some_and(advanced_color)); + } + Found::System(e) => { + eprintln!("{name}: system, {e}"); + assert!(path.as_ref().is_some_and(advanced_color)); + } + Found::NoProfile(e) => { + eprintln!("{name}: no profile, {e}"); + assert!(!e.is_empty()); + } + } + } + } + } +} + +#[cfg(target_os = "linux")] +mod x11 { + use std::collections::HashMap; + use std::path::Path; + use std::time::Duration; + + use vectorcraft_ui_egui::display_profile::{Found, MAX_PROFILE_BYTES}; + use x11rb::connection::Connection as _; + use x11rb::protocol::randr; + use x11rb::protocol::xproto::{self, AtomEnum}; + use zbus::blocking::{connection::Builder, proxy::Proxy}; + use zbus::zvariant::OwnedObjectPath; + + const COLORD: &str = "org.freedesktop.ColorManager"; + const COLORD_ROOT: &str = "/org/freedesktop/ColorManager"; + const COLORD_MANAGER: &str = "org.freedesktop.ColorManager"; + const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device"; + const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile"; + + pub(super) fn read(output: Option<&str>) -> Found { + let colord = output.map(|o| colord_profile(o).map(|path| path.map(|path| super::usable(super::profile_file(Path::new(&path)))))); + super::x11_choice(colord, || properties(output)) + } + + /// The file colord assigns the RandR output `output`. + fn colord_profile(output: &str) -> Result, String> { + let connection = Builder::system() + .map_err(|e| format!("couldn't connect to the system bus: {e}"))? + .method_timeout(Duration::from_secs(2)) + .build() + .map_err(|e| format!("couldn't connect to the system bus: {e}"))?; + let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't reach colord: {e}"))?; + let devices: Vec = + manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list the displays: {e}"))?; + for path in devices { + let Ok(device) = Proxy::new(&connection, COLORD, &path, COLORD_DEVICE) else { continue }; + let Ok(metadata) = device.get_property::>("Metadata") else { continue }; + if metadata.get("XRANDR_name").map(String::as_str) != Some(output) { + continue; + } + // `*` asks for the device's default profile. + let Ok(profile_path) = device.call::<_, _, OwnedObjectPath>("GetProfileForQualifiers", &(vec!["*"],)) else { return Ok(None) }; + let profile = + Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't reach colord's profile: {e}"))?; + return Ok(profile.get_property::("Filename").ok().filter(|f| !f.is_empty())); + } + Ok(None) + } + + /// The `_ICC_PROFILE` property of the RandR output `output`, or of the screen when only one + /// output is active. + fn properties(output: Option<&str>) -> Result, String> { + let (conn, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?; + let root = conn.setup().roots.get(screen).ok_or_else(|| "the X11 display has no screen".to_string())?.root; + let atom = xproto::intern_atom(&conn, true, b"_ICC_PROFILE") + .map_err(|e| format!("couldn't ask the X11 display for profiles: {e}"))? + .reply() + .map_err(|e| format!("couldn't ask the X11 display for profiles: {e}"))? + .atom; + if atom == 0 { + return Ok(None); + } + let resources = randr::get_screen_resources_current(&conn, root) + .map_err(|e| format!("couldn't list the X11 outputs: {e}"))? + .reply() + .map_err(|e| format!("couldn't list the X11 outputs: {e}"))?; + let (mut active, mut shown) = (0usize, None); + for o in resources.outputs { + let Ok(Ok(info)) = randr::get_output_info(&conn, o, resources.config_timestamp).map(|c| c.reply()) else { continue }; + if info.connection != randr::Connection::CONNECTED || info.crtc == 0 { + continue; + } + active += 1; + if output.is_some_and(|name| info.name == name.as_bytes()) { + shown = Some(o); + } + } + let longs = (MAX_PROFILE_BYTES / 4) as u32; + let too_large = || Found::NoProfile(format!("the display's profile is larger than {} MiB", MAX_PROFILE_BYTES >> 20)); + if let Some(o) = shown + && let Ok(Ok(p)) = randr::get_output_property(&conn, o, atom, AtomEnum::ANY, 0, longs, false, false).map(|c| c.reply()) + && !p.data.is_empty() + { + if p.bytes_after > 0 { + return Ok(Some(too_large())); + } + return Ok(Some(Found::Profile { icc: p.data, from: "the X11 output's _ICC_PROFILE".into() })); + } + if active != 1 { + return Ok(None); + } + let Ok(Ok(p)) = xproto::get_property(&conn, false, root, atom, AtomEnum::ANY, 0, longs).map(|c| c.reply()) else { return Ok(None) }; + if p.value.is_empty() { + return Ok(None); + } + if p.bytes_after > 0 { + return Ok(Some(too_large())); + } + Ok(Some(Found::Profile { icc: p.value, from: "the X11 screen's _ICC_PROFILE".into() })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_reading_is_due_first_after_ten_seconds_and_when_the_window_changes_display() { + assert!(due(None, 0.0, None)); + let last = (100.0, Some("DP-1".to_string())); + assert!(!due(Some(&last), 100.0, Some("DP-1"))); + assert!(!due(Some(&last), 109.9, Some("DP-1"))); + assert!(due(Some(&last), 110.0, Some("DP-1"))); + assert!(due(Some(&last), 101.0, Some("HDMI-1"))); + assert!(due(Some(&last), 101.0, None)); + // A clock that went back. + assert!(due(Some(&last), 50.0, Some("DP-1"))); + } + + #[test] + fn wayland_sessions_are_told_apart_from_x11_by_the_environment() { + let env = |pairs: &'static [(&'static str, &'static str)]| { + move |k: &str| pairs.iter().find(|(n, _)| *n == k).map(|(_, v)| std::ffi::OsString::from(v)) + }; + assert!(wayland_session(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]))); + assert!(wayland_session(env(&[("WAYLAND_SOCKET", "5")]))); + assert!(!wayland_session(env(&[("DISPLAY", ":0")]))); + assert!(!wayland_session(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":0")]))); + } + + #[test] + fn profile_files_larger_than_the_limit_are_refused_before_they_are_read() { + let dir = tempfile::tempdir().unwrap(); + let small = dir.path().join("small.icc"); + std::fs::write(&small, [1u8, 2, 3]).unwrap(); + assert_eq!(read_icc(&small).unwrap(), [1, 2, 3]); + let big = dir.path().join("big.icc"); + let f = std::fs::File::create(&big).unwrap(); + f.set_len(vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES as u64 + 1).unwrap(); + assert!(read_icc(&big).unwrap_err().contains("larger than 1 MiB")); + assert!(read_icc(&dir.path().join("missing.icc")).unwrap_err().starts_with("couldn't open")); + } + + #[test] + fn x11_takes_the_output_property_when_colord_s_file_cannot_be_read() { + let icc = |b: u8| Found::Profile { icc: vec![b], from: "test".into() }; + let property = || Ok(Some(icc(2))); + // colord's profile, read: the property is not asked for. + assert_eq!(x11_choice(Some(Ok(Some(icc(1)))), || panic!("not asked")), icc(1)); + // colord names a file that can't be read, or has no profile, or isn't running. + assert_eq!(x11_choice(Some(Ok(Some(Found::NoProfile("couldn't open a.icc".into())))), property), icc(2)); + assert_eq!(x11_choice(Some(Ok(None)), property), icc(2)); + assert_eq!(x11_choice(Some(Err("no colord".into())), property), icc(2)); + assert_eq!(x11_choice(None, property), icc(2)); + // Neither has a profile: colord's reason is kept. + assert_eq!( + x11_choice(Some(Ok(Some(Found::NoProfile("couldn't open a.icc".into())))), || Ok(None)), + Found::NoProfile("no profile is set for this display (couldn't open a.icc)".into()) + ); + assert_eq!(x11_choice(None, || Ok(None)), Found::NoProfile("no profile is set for this display".into())); + assert_eq!(x11_choice(None, || Err("no X11".into())), Found::NoProfile("no X11".into())); + // colord's file holds no usable profile: the property is used. + let broken = usable(Found::Profile { icc: vec![1, 2, 3], from: "a.icc".into() }); + assert_eq!(broken, Found::NoProfile("a.icc: the file is not an ICC profile".into())); + assert_eq!(x11_choice(Some(Ok(Some(broken))), property), icc(2)); + assert_eq!(usable(Found::NoProfile("none".into())), Found::NoProfile("none".into())); + } + + #[test] + fn a_profile_name_is_found_in_the_color_folder_and_a_path_is_kept() { + let dir = std::path::Path::new("C:/Windows/System32/spool/drivers/color"); + assert_eq!(profile_path(Some(dir), "Display.icm"), dir.join("Display.icm")); + assert_eq!(profile_path(Some(dir), "D:\\profiles\\Display.icm"), std::path::PathBuf::from("D:\\profiles\\Display.icm")); + assert_eq!(profile_path(None, "Display.icm"), std::path::PathBuf::from("Display.icm")); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index cd3ea7cd36..7594bc2344 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -28,6 +28,7 @@ mod clipboard; mod compositor_gpu; mod control_server; mod cursor_theme; +mod display_profile; #[cfg(feature = "wgpu")] mod gpu; mod hang_watch; @@ -131,6 +132,8 @@ struct App { held_files: Vec, /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). monitors_listed: Option<(f64, f32)>, + /// Reads the profile of the display that shows the main window, for the canvas. + display_profile: display_profile::Reader, /// When this frame's input hook started, for the hang report's frame times (`hang_watch`). frame_start: Option, } @@ -634,6 +637,7 @@ fn main() -> std::process::ExitCode { maximize_tries: 8, held_files, monitors_listed: None, + display_profile: Default::default(), frame_start: None, })) }), diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index f25615c589..080462d0b6 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -762,9 +762,40 @@ pub fn library(doc: &Document) -> Vec { } /// Parse a stored library value (malformed entries are skipped, values kept within their -/// ranges; see [`Brush::from_json`]). +/// ranges; see [`Brush::from_json`]). A name that repeats is kept by its first brush, and each +/// later brush of that name is numbered ([`number_repeated_names`]). pub fn parse_library(v: &serde_json::Value) -> Vec { - v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default() + let mut lib: Vec = v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default(); + number_repeated_names(&mut lib); + lib +} + +/// Renames the second and later brushes of each repeated name in `lib` to the first "Name 2", +/// "Name 3"… that no brush in `lib` has. The first brush of a name keeps it, so a stroke that names +/// it draws the brush [`find`] gave it before. +fn number_repeated_names(lib: &mut [Brush]) { + let mut seen = std::collections::HashSet::new(); + if lib.iter().all(|b| seen.insert(b.name.as_str())) { + return; + } + let mut taken: std::collections::HashSet = lib.iter().map(|b| b.name.clone()).collect(); + let mut kept = std::collections::HashSet::new(); + // The next number to try for each repeated name, so the brushes of one name try each number + // once between them. + let mut next: std::collections::HashMap = std::collections::HashMap::new(); + for b in lib.iter_mut() { + if kept.insert(b.name.clone()) { + continue; + } + let from = next.entry(b.name.clone()).or_insert(2); + // `taken` is finite, so one of the numbers is free. + if let Some((i, name)) = (*from..).map(|i| (i, format!("{} {i}", b.name))).find(|(_, n)| !taken.contains(n)) { + *from = i.saturating_add(1); + taken.insert(name.clone()); + kept.insert(name.clone()); + b.name = name; + } + } } /// Store `lib` as the document's brush library. diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index affd2fa6b7..2d5156d162 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -945,6 +945,34 @@ fn library_defaults_store_and_find() { assert_eq!(unique_name(&lib, "Arrow"), "Arrow 2"); } +/// A stored library that repeats a name reads with the first brush of that name as stored and each +/// later one numbered, past every name the library already has; strokes of the name keep drawing +/// the first brush. +#[test] +fn repeated_brush_names_are_numbered_when_read() { + let mut d = Document::new(100.0, 100.0); + let named = |name: &str, size: f64| Brush { name: name.into(), ..calli(0.0, 100.0, size) }; + store(&mut d, &[named("Nib", 1.0), named("Nib 2", 2.0), named("Nib", 3.0), named("Other", 4.0), named("Nib", 5.0), named("Nib 2", 6.0)]); + let lib = library(&d); + let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).collect(); + assert_eq!(names, ["Nib", "Nib 2", "Nib 3", "Other", "Nib 4", "Nib 2 2"]); + let size = |b: &Brush| if let BrushKind::Calligraphic(c) = &b.kind { c.size } else { 0.0 }; + assert_eq!(lib.iter().map(size).collect::>(), [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + assert_eq!(find(&d, "Nib").map(|b| size(&b)), Some(1.0)); + assert_eq!(find(&d, "Nib 3").map(|b| size(&b)), Some(3.0)); + // Reading again gives the same names, and the numbered library stored again reads as stored. + assert_eq!(library(&d), lib); + store(&mut d, &lib); + assert_eq!(library(&d), lib); + // Many brushes of one name take the numbers in order, past the numbered names the file has. + let many: Vec = (0..5000).map(|i| named(if i == 1 { "Nib 3" } else { "Nib" }, 1.0)).collect(); + store(&mut d, &many); + let names: Vec = library(&d).into_iter().map(|b| b.name).collect(); + assert_eq!(names[..5], ["Nib", "Nib 3", "Nib 2", "Nib 4", "Nib 5"]); + assert_eq!(names.last().map(String::as_str), Some("Nib 5000")); + assert_eq!(names.iter().collect::>().len(), names.len()); +} + #[test] fn brushes_serde_round_trip() { let lib = defaults().to_vec(); diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index c5e5bc5bea..f4297343a6 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -2,7 +2,8 @@ //! gamut checks and soft-proof transforms. //! //! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Wide Gamut RGB -//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB. +//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB; +//! the desktop canvas converts it into the display's own profile last ([`display`]). //! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model //! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers); //! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles). @@ -10,6 +11,7 @@ //! the profiles [`use_document_profiles`] last put in place of the working spaces; //! [`crate::Color::to_rgb`] uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`]. +pub mod display; pub mod generic; pub mod icc; pub mod lab; diff --git a/crates/color/src/cms/display.rs b/crates/color/src/cms/display.rs new file mode 100644 index 0000000000..03fe00220c --- /dev/null +++ b/crates/color/src/cms/display.rs @@ -0,0 +1,327 @@ +//! The canvas's last step before the screen: the sRGB the canvas draws, converted into a display's +//! ICC profile (Preferences › Performance › Use Monitor Profile for Canvas). +//! +//! The display profile is a destination only. Document colors, proofs, exports and the web build +//! keep their sRGB output; a soft proof is applied first, then this conversion. +//! +//! [`DisplayTransform::from_icc`] accepts an RGB display-class (`mntr`) profile of at most +//! [`MAX_PROFILE_BYTES`] whose declared length fits the data and from which a transform can be built. +//! The conversion is relative colorimetric, sampled once into a [`LUT_N`]³ table, so drawing a frame +//! costs a table lookup per pixel and never builds a transform. + +use std::hash::{Hash, Hasher}; + +use super::{Intent, ProfileKind, icc::IccProfile}; + +/// The largest display profile read: VectorCraft's own limit. Display profiles are a few kilobytes +/// (matrix/shaper) to a few hundred (table-based). +pub const MAX_PROFILE_BYTES: usize = 1 << 20; + +/// Grid points per channel of the conversion table. +const LUT_N: usize = 33; + +/// Why a display profile can't be used. The canvas then stays sRGB. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProfileError { + /// More bytes than [`MAX_PROFILE_BYTES`]. + TooLarge(usize), + /// No ICC header (`acsp` signature) or a header cut short. + NotIcc, + /// The header's length is shorter than a header or longer than the data. + BadLength { declared: usize, len: usize }, + /// The profile class isn't a display's (`mntr`): an input, output or other profile. + NotDisplay(String), + /// The profile's data color space isn't RGB. + NotRgb(String), + /// The profile doesn't parse. + Unreadable(String), + /// No conversion from sRGB into the profile can be built. + NoTransform, +} + +impl std::fmt::Display for ProfileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TooLarge(n) => write!(f, "the profile is {n} bytes, more than the {MAX_PROFILE_BYTES} a display profile may have"), + Self::NotIcc => f.write_str("the file is not an ICC profile"), + Self::BadLength { declared, len } => write!(f, "the profile's header gives a length of {declared} bytes for {len} bytes of data"), + Self::NotDisplay(class) => write!(f, "the profile's class is `{class}`, not a display profile (`mntr`)"), + Self::NotRgb(space) => write!(f, "the profile's color space is `{space}`, not RGB"), + Self::Unreadable(e) => write!(f, "the profile can't be read: {e}"), + Self::NoTransform => f.write_str("no conversion into the profile can be built"), + } + } +} + +/// Four header bytes as text, for an error message. +fn signature(b: &[u8]) -> String { + String::from_utf8_lossy(b).trim_end().to_string() +} + +/// sRGB → a display profile, as a table. +pub struct DisplayTransform { + name: String, + fingerprint: u64, + /// `LUT_N`³ display values, red slowest; `None` when the profile shows sRGB unchanged (every + /// grid point within half an 8-bit step of itself). + table: Option>, +} + +impl std::fmt::Debug for DisplayTransform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DisplayTransform").field("name", &self.name).field("identity", &self.is_identity()).finish() + } +} + +impl DisplayTransform { + /// Checks `bytes` as a display profile, as [`Self::from_icc`] does, without building the + /// conversion table. + pub fn check(bytes: &[u8]) -> Result<(), ProfileError> { + Self::parse(bytes).map(|_| ()) + } + + /// Checks `bytes` as a display profile and builds the conversion into it. + pub fn from_icc(bytes: &[u8]) -> Result { + let (profile, bytes) = Self::parse(bytes)?; + let to_display = + |rgb: [f32; 3]| profile.from_srgb(rgb, Intent::RelativeColorimetric).and_then(|v| Some([*v.first()?, *v.get(1)?, *v.get(2)?])); + let s = (LUT_N - 1) as f32; + let mut table = Vec::with_capacity(LUT_N.pow(3)); + for r in 0..LUT_N { + for g in 0..LUT_N { + for b in 0..LUT_N { + let rgb = [r as f32 / s, g as f32 / s, b as f32 / s]; + table.push(to_display(rgb).ok_or(ProfileError::NoTransform)?); + } + } + } + let grid = |i: usize| [i / (LUT_N * LUT_N), i / LUT_N % LUT_N, i % LUT_N].map(|v| v as f32 / s); + let identity = table.iter().enumerate().all(|(i, v)| v.iter().zip(grid(i)).all(|(a, b)| (a - b).abs() <= 0.5 / 255.0)); + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + Ok(Self { name: profile.name.clone(), fingerprint: h.finish(), table: (!identity).then_some(table) }) + } + + /// The parsed profile and its bytes up to its declared length, when `bytes` is an RGB display + /// profile from which a conversion can be built. + fn parse(bytes: &[u8]) -> Result<(IccProfile, &[u8]), ProfileError> { + if bytes.len() > MAX_PROFILE_BYTES { + return Err(ProfileError::TooLarge(bytes.len())); + } + if bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") { + return Err(ProfileError::NotIcc); + } + let declared = bytes.get(0..4).map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize); + if declared < 132 || declared > bytes.len() { + return Err(ProfileError::BadLength { declared, len: bytes.len() }); + } + let bytes = &bytes[..declared]; + if &bytes[12..16] != b"mntr" { + return Err(ProfileError::NotDisplay(signature(&bytes[12..16]))); + } + if &bytes[16..20] != b"RGB " { + return Err(ProfileError::NotRgb(signature(&bytes[16..20]))); + } + let profile = IccProfile::from_bytes(None, bytes).map_err(|e| ProfileError::Unreadable(e.to_string()))?; + if profile.kind != ProfileKind::Rgb { + return Err(ProfileError::NotRgb(format!("{:?}", profile.kind))); + } + let to_display = + |rgb: [f32; 3]| profile.from_srgb(rgb, Intent::RelativeColorimetric).and_then(|v| Some([*v.first()?, *v.get(1)?, *v.get(2)?])); + if to_display([0.5; 3]).is_none() { + return Err(ProfileError::NoTransform); + } + Ok((profile, bytes)) + } + + /// The profile's description. + pub fn name(&self) -> &str { + &self.name + } + + /// A hash of the profile's bytes, which tells two profiles with the same name apart. + pub fn fingerprint(&self) -> u64 { + self.fingerprint + } + + /// The profile shows sRGB unchanged, so nothing is converted. + pub fn is_identity(&self) -> bool { + self.table.is_none() + } + + /// One sRGB color (0..1) in the display profile. + pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { + let Some(table) = &self.table else { return rgb.map(|v| v.clamp(0.0, 1.0)) }; + let n = LUT_N; + let s = (n - 1) as f32; + let p = rgb.map(|v| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) * s }); + let i = p.map(|v| (v.floor() as usize).min(n - 2)); + let t = [p[0] - i[0] as f32, p[1] - i[1] as f32, p[2] - i[2] as f32]; + let mut out = [0.0f32; 3]; + for (dr, wr) in [(0, 1.0 - t[0]), (1, t[0])] { + for (dg, wg) in [(0, 1.0 - t[1]), (1, t[1])] { + for (db, wb) in [(0, 1.0 - t[2]), (1, t[2])] { + let w = wr * wg * wb; + if w == 0.0 { + continue; + } + let v = table[((i[0] + dr) * n + i[1] + dg) * n + i[2] + db]; + for c in 0..3 { + out[c] += w * v[c]; + } + } + } + } + out.map(|v| v.clamp(0.0, 1.0)) + } + + /// One 8-bit sRGB color in the display profile. + pub fn apply8(&self, rgb: [u8; 3]) -> [u8; 3] { + if self.table.is_none() { + return rgb; + } + self.apply(rgb.map(|v| f32::from(v) / 255.0)).map(|v| (v * 255.0).round() as u8) + } + + /// Premultiplied RGBA8 pixels, converted in place. Alpha is kept, and fully transparent + /// pixels are left as they are. + pub fn apply_premultiplied(&self, pixels: &mut [u8]) { + if self.table.is_none() { + return; + } + let mut last_in = [0u8; 4]; + let mut last_out = [0u8; 4]; + for px in pixels.as_chunks_mut::<4>().0 { + let a = px[3]; + if a == 0 { + continue; + } + if *px == last_in { + *px = last_out; + continue; + } + last_in = *px; + let un = |c: u8| if a == 255 { c } else { ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 }; + let out = self.apply8([un(px[0]), un(px[1]), un(px[2])]); + for (c, o) in px.iter_mut().zip(out) { + *c = if a == 255 { o } else { ((u32::from(o) * u32::from(a) + 127) / 255) as u8 }; + } + last_out = *px; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cms::{DISPLAY_P3, GENERIC_CMYK, SRGB, icc_bytes}; + + /// `name`'s ICC file, marked as a display profile. + fn display(name: &str) -> Vec { + let mut b = icc_bytes(name).unwrap().to_vec(); + b[12..16].copy_from_slice(b"mntr"); + b + } + + #[test] + fn a_wide_gamut_display_shows_srgb_red_less_saturated_and_srgb_is_left_alone() { + let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap(); + assert!(!p3.is_identity()); + assert_eq!(p3.name(), DISPLAY_P3); + // sRGB red is inside Display P3: its display values have green and blue above 0 and less red. + let [r, g, b] = p3.apply8([255, 0, 0]); + assert!(r < 245 && g > 40 && b > 10, "{:?}", [r, g, b]); + // White and black stay where they are (relative colorimetric). + assert_eq!(p3.apply8([255; 3]), [255; 3]); + assert_eq!(p3.apply8([0; 3]), [0; 3]); + let srgb = DisplayTransform::from_icc(&display(SRGB)).unwrap(); + assert!(srgb.is_identity()); + assert_eq!(srgb.apply8([255, 0, 0]), [255, 0, 0]); + assert_ne!(srgb.fingerprint(), p3.fingerprint()); + } + + #[test] + fn the_table_matches_the_profile_between_its_grid_points() { + let bytes = display(DISPLAY_P3); + let p3 = DisplayTransform::from_icc(&bytes).unwrap(); + let profile = IccProfile::from_bytes(None, &bytes).unwrap(); + for rgb in [[0.1, 0.7, 0.33], [0.9, 0.05, 0.6], [0.47, 0.47, 0.47], [0.0, 0.2, 1.0]] { + let want = profile.from_srgb(rgb, Intent::RelativeColorimetric).unwrap(); + let got = p3.apply(rgb); + for c in 0..3 { + assert!((got[c] - want[c]).abs() < 1.0 / 255.0, "{rgb:?}: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn premultiplied_pixels_keep_their_alpha_and_transparent_ones_stay_empty() { + let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap(); + let mut px = vec![255, 0, 0, 255, 128, 0, 0, 128, 0, 0, 0, 0, 255, 0, 0, 255]; + p3.apply_premultiplied(&mut px); + let opaque = p3.apply8([255, 0, 0]); + assert_eq!(&px[0..4], &[opaque[0], opaque[1], opaque[2], 255]); + assert_eq!(&px[12..16], &px[0..4], "a repeated pixel converts the same"); + assert_eq!(px[7], 128); + // Half-covered red: the same color at half coverage, within rounding. + for c in 0..3 { + assert!((i32::from(px[4 + c]) - i32::from(opaque[c]) / 2).abs() <= 1, "{:?}", &px[4..8]); + } + assert_eq!(&px[8..12], &[0, 0, 0, 0]); + } + + #[test] + fn profiles_that_are_not_rgb_display_profiles_are_refused() { + let cmyk = icc_bytes(GENERIC_CMYK).unwrap().to_vec(); + assert!(matches!(DisplayTransform::from_icc(&cmyk), Err(ProfileError::NotDisplay(_)))); + let mut cmyk_display = cmyk.clone(); + cmyk_display[12..16].copy_from_slice(b"mntr"); + assert!(matches!(DisplayTransform::from_icc(&cmyk_display), Err(ProfileError::NotRgb(s)) if s == "CMYK")); + let mut input = display(DISPLAY_P3); + input[12..16].copy_from_slice(b"scnr"); + assert_eq!(DisplayTransform::from_icc(&input).unwrap_err(), ProfileError::NotDisplay("scnr".into())); + } + + #[test] + fn malformed_short_and_oversized_data_is_refused() { + assert_eq!(DisplayTransform::from_icc(&[]).unwrap_err(), ProfileError::NotIcc); + assert_eq!(DisplayTransform::from_icc(&[0; 132]).unwrap_err(), ProfileError::NotIcc); + let good = display(DISPLAY_P3); + assert_eq!(DisplayTransform::from_icc(&good[..100]).unwrap_err(), ProfileError::NotIcc); + let mut long = good.clone(); + long[0..4].copy_from_slice(&(good.len() as u32 + 1).to_be_bytes()); + assert_eq!(DisplayTransform::from_icc(&long).unwrap_err(), ProfileError::BadLength { declared: good.len() + 1, len: good.len() }); + let mut short = good.clone(); + short[0..4].copy_from_slice(&64u32.to_be_bytes()); + assert!(matches!(DisplayTransform::from_icc(&short), Err(ProfileError::BadLength { declared: 64, .. }))); + // Trailing bytes past the declared length are not part of the profile. + let mut padded = good.clone(); + padded.extend([0xAB; 16]); + let a = DisplayTransform::from_icc(&padded).unwrap(); + assert_eq!(a.fingerprint(), DisplayTransform::from_icc(&good).unwrap().fingerprint()); + let mut garbage_tags = good.clone(); + for b in &mut garbage_tags[128..] { + *b = 0xFF; + } + assert!(matches!(DisplayTransform::from_icc(&garbage_tags), Err(ProfileError::Unreadable(_) | ProfileError::NoTransform))); + let huge = vec![0u8; MAX_PROFILE_BYTES + 1]; + assert_eq!(DisplayTransform::from_icc(&huge).unwrap_err(), ProfileError::TooLarge(MAX_PROFILE_BYTES + 1)); + } + + #[test] + fn check_accepts_and_refuses_what_from_icc_does() { + assert_eq!(DisplayTransform::check(&display(DISPLAY_P3)), Ok(())); + assert_eq!(DisplayTransform::check(&display(SRGB)), Ok(())); + let cmyk = icc_bytes(GENERIC_CMYK).unwrap().to_vec(); + assert_eq!(DisplayTransform::check(&cmyk), Err(DisplayTransform::from_icc(&cmyk).unwrap_err())); + assert_eq!(DisplayTransform::check(&[1, 2, 3]), Err(ProfileError::NotIcc)); + } + + #[test] + fn nan_and_out_of_range_values_stay_in_range() { + let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap(); + for v in p3.apply([f32::NAN, -1.0, 9.0]) { + assert!((0.0..=1.0).contains(&v)); + } + } +} diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d170bd606f..b84de1cac3 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1205,6 +1205,13 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> // Spine // ===================================================================================== +#[cfg(test)] +thread_local! { + /// How many times [`Spine::new`] has run on this thread, for tests that count how often a path + /// is flattened. + pub(crate) static SPINES_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A spine (the first subpath of a path) flattened to a polyline, with cumulative lengths and the /// arc length at each of its anchors. pub struct Spine { @@ -1223,6 +1230,8 @@ impl Spine { /// arc lengths, and the arc length at each anchor. `None` when `path` has no subpath, or when /// the first one flattens to fewer than two points (so it has no length to walk). pub fn new(path: &PathData) -> Option { + #[cfg(test)] + SPINES_BUILT.with(|n| n.set(n.get() + 1)); let sp = path.subpaths.first()?; let mut pts: Vec = Vec::new(); let mut cum: Vec = Vec::new(); @@ -1455,7 +1464,7 @@ pub fn pin_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Vec< } /// Do `keys` sit where a spine from Replace Spine spreads them: evenly along `spine` by arc length -/// (within a hundredth of a point, as [`anchor_at_length`] matches anchors)? +/// (within a hundredth of a point, as [`SpreadLengths::anchor_at_length`] matches anchors)? fn keys_spread_along(keys: &[Arc], spine: &Spine) -> bool { let k = keys.len(); keys.iter().enumerate().all(|(i, key)| { @@ -1480,40 +1489,204 @@ fn open_at_start(sp: &mut SubPath, ka: &mut [usize]) { } /// The anchors of `path`'s first subpath that `k` keys spread evenly along it by arc length sit -/// on, splitting segments where none is. +/// on, splitting segments where none is. `None`, leaving `path` as it was, when the subpath has no +/// length to spread along, when its length is not finite (a path so large its length overflows), +/// or when an arc length it would split at is not finite. fn spread_anchors(path: &mut PathData, k: usize) -> Option> { - let total = Spine::new(path)?.length(); - let closed = path.subpaths.first().is_some_and(|s| s.closed); + spread_anchors_with(path, k, |_| {}) +} + +/// [`spread_anchors`], calling `work` for each step of work it does. +fn spread_anchors_with(path: &mut PathData, k: usize, mut work: impl FnMut(SpreadWork)) -> Option> { + let sp = path.subpaths.first_mut()?; + let lengths = SpreadLengths::new(sp, &mut work); + let total = lengths.total().filter(|t| t.is_finite())?; + let targets = (1..k.saturating_sub(1)).map(|i| total * i as f64 / (k - 1) as f64); let mut out = vec![0usize]; - for i in 1..k.saturating_sub(1) { - let s = total * i as f64 / (k - 1) as f64; - out.push(anchor_at_length(path, s)?); - } - let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) }); - out.push(last); + out.extend(anchors_at_lengths(sp, lengths, targets, &mut work)?); + out.push(if sp.closed { 0 } else { sp.anchors.len().saturating_sub(1) }); Some(out) } -/// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a -/// point, else a new one splitting the segment there. -fn anchor_at_length(path: &mut PathData, s: f64) -> Option { - let spine = Spine::new(path)?; - let sp = path.subpaths.first_mut()?; - let n = sp.segment_count(); - let seg = (0..n).rev().find(|i| spine.anchor_length(*i).is_some_and(|l| l <= s)).unwrap_or(0); - let from = spine.anchor_length(seg).unwrap_or(0.0); - let to = spine.anchor_length(seg + 1).unwrap_or(spine.length()); - if s - from < 0.01 { - return Some(seg); +/// The anchors of `sp` at each arc length in `targets`, in order, splitting segments where none is +/// (see [`SpreadLengths::anchor_at_length`]); `lengths` measures `sp`. The splits are made on a copy +/// of `sp`, which replaces it only when every target has its anchor: `None` leaves `sp` as it was. +fn anchors_at_lengths( + sp: &mut SubPath, + mut lengths: SpreadLengths, + targets: impl IntoIterator, + work: &mut impl FnMut(SpreadWork), +) -> Option> { + let mut split = sp.clone(); + let mut out = Vec::new(); + for s in targets { + out.push(lengths.anchor_at_length(&mut split, s, work)?); } - if to - s < 0.01 { - return Some((seg + 1) % sp.anchors.len().max(1)); + *sp = split; + Some(out) +} + +/// A step of the work [`spread_anchors_with`] does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SpreadWork { + /// Flattening every segment of the subpath. + WholePath, + /// Flattening one segment after a split. + Segment, + /// Adding one segment's flattened points to the arc lengths. + Measure, + /// Comparing an anchor's arc length with the one looked for. + Compare, +} + +/// Where the measurement of a subpath's flattened points stands at the start of a segment: the last +/// point kept, the arc length there (0 before any point), and how many points were kept. +#[derive(Clone, Copy)] +struct Measured { + last: Option, + len: f64, + kept: usize, +} + +impl Measured { + const START: Self = Self { last: None, len: 0.0, kept: 0 }; + + /// The measurement after `pts`, keeping each point more than 1e-9 from the last one kept, as + /// [`Spine::new`] does. + fn then(mut self, pts: &[Point]) -> Self { + for &p in pts { + if self.last.is_none_or(|q| q.distance(p) > 1e-9) { + self.len = match self.last { + Some(q) => self.len + q.distance(p), + None => 0.0, + }; + self.last = Some(p); + self.kept += 1; + } + } + self + } +} + +/// The arc lengths of a subpath's anchors, as [`Spine::new`] measures them, kept up to date while +/// [`spread_anchors`] splits its segments. Each segment is flattened once, and again only when it +/// is split. A split leaves the arc lengths of the anchors before it as they are; those after it +/// are measured again, in order from the split, only as far as the next arc length looked for, so +/// the sums are the same as [`Spine::new`]'s on the split subpath. +struct SpreadLengths { + /// The finite points the segments flatten to, each segment's in one run. + pts: Vec, + /// Where each segment's run is in `pts`. A split segment's halves get runs at the end, and its + /// old run is not used again. + flat: Vec>, + /// The measurement at the start of each segment, up to the first one not measured since the + /// last split; after the last segment too once every segment is measured. + marks: Vec, +} + +impl SpreadLengths { + /// Flatten and measure every segment of `sp`. + fn new(sp: &SubPath, work: &mut impl FnMut(SpreadWork)) -> Self { + work(SpreadWork::WholePath); + let n = sp.segment_count(); + let mut lengths = Self { pts: Vec::new(), flat: Vec::with_capacity(n), marks: Vec::with_capacity(n + 1) }; + for i in 0..n { + let run = lengths.flatten(sp, i); + lengths.flat.push(run); + } + lengths.marks.push(Measured::START); + lengths.measure_to(f64::INFINITY, work); + lengths + } + + /// Flatten segment `i` of `sp` as [`Spine::new`] does, adding its finite points to `pts`, and + /// return where they are. + fn flatten(&mut self, sp: &SubPath, i: usize) -> std::ops::Range { + let start = self.pts.len(); + let c = sp.segment(i); + let mut bp = BezPath::new(); + bp.move_to(c.p0); + if sp.segment_is_line(i) { + bp.line_to(c.p3); + } else { + bp.curve_to(c.p1, c.p2, c.p3); + } + vectorcraft_geom::kurbo::flatten(bp.iter(), 0.1, |el| { + if let vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) = el + && p.x.is_finite() + && p.y.is_finite() + { + self.pts.push(p); + } + }); + start..self.pts.len() + } + + /// The subpath's flattened length, `None` when it flattens to fewer than two points. + fn total(&self) -> Option { + self.marks.last().filter(|m| self.marks.len() > self.flat.len() && m.kept >= 2).map(|m| m.len) + } + + /// Measure the segments that are not measured, from the first, until one starts past `s` (with + /// two points kept before it) or every one is measured. + fn measure_to(&mut self, s: f64, work: &mut impl FnMut(SpreadWork)) { + while let Some(run) = self.flat.get(self.marks.len() - 1) { + let Some(&m) = self.marks.last() else { return }; + if self.marks.len() >= 2 && m.kept >= 2 && m.len > s { + return; + } + work(SpreadWork::Measure); + self.marks.push(m.then(self.pts.get(run.clone()).unwrap_or_default())); + } + } + + /// The anchor of `sp` at arc length `s`: an existing one within a hundredth of a point, else a + /// new one splitting the segment there. The segment is the last one starting at or before `s` + /// (the first one when none does), found by a binary search. `None`, leaving `sp` as it was, + /// when `s` or the arc length at the end of the segment is not finite, or when `sp` flattens + /// to fewer than two points. + fn anchor_at_length(&mut self, sp: &mut SubPath, s: f64, work: &mut impl FnMut(SpreadWork)) -> Option { + if !s.is_finite() { + return None; + } + self.measure_to(s, work); + let n = self.flat.len(); + if self.marks.last().is_none_or(|m| m.kept < 2) { + return None; + } + let starts = &self.marks[..self.marks.len().min(n)]; + let seg = starts + .partition_point(|m| { + work(SpreadWork::Compare); + m.len <= s + }) + .saturating_sub(1); + let from = self.marks.get(seg)?.len; + let to = self.marks.get(seg + 1)?.len; + if s - from < 0.01 { + return Some(seg); + } + if to - s < 0.01 { + return Some((seg + 1) % sp.anchors.len().max(1)); + } + if !from.is_finite() || !to.is_finite() { + return None; + } + let c = sp.segment(seg); + // The flattened length and the curve's own differ slightly: scale into the curve's. + let arc = c.arclen(1e-4).max(1e-12); + let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4); + if !t.is_finite() { + return None; + } + let at = sp.insert_anchor(seg, t); + work(SpreadWork::Segment); + work(SpreadWork::Segment); + let halves = [self.flatten(sp, seg), self.flatten(sp, seg + 1)]; + self.flat.splice(seg..=seg, halves); + self.marks.truncate(seg + 1); + Some(at) } - let c = sp.segment(seg); - // The flattened length and the curve's own differ slightly: scale into the curve's. - let arc = c.arclen(1e-4).max(1e-12); - let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4); - Some(sp.insert_anchor(seg, t)) } /// Where a blend's keys and steps go along its spine (see [`blend_spine`]). @@ -2003,6 +2176,150 @@ mod tests { } } + /// [`spread_anchors`] by measuring the whole spine again for each key and walking its segments + /// from the last, as a reference for the measurement kept up to date between keys. + fn rebuilt_spread_anchors(path: &mut PathData, k: usize) -> Option> { + let total = Spine::new(path)?.length(); + let closed = path.subpaths.first().is_some_and(|s| s.closed); + let mut out = vec![0usize]; + for i in 1..k.saturating_sub(1) { + let s = total * i as f64 / (k - 1) as f64; + let spine = Spine::new(path)?; + let sp = path.subpaths.first_mut()?; + let n = sp.segment_count(); + let seg = (0..n).rev().find(|i| spine.anchor_length(*i).is_some_and(|l| l <= s)).unwrap_or(0); + let from = spine.anchor_length(seg).unwrap_or(0.0); + let to = spine.anchor_length(seg + 1).unwrap_or(spine.length()); + out.push(if s - from < 0.01 { + seg + } else if to - s < 0.01 { + (seg + 1) % sp.anchors.len().max(1) + } else { + let c = sp.segment(seg); + let arc = c.arclen(1e-4).max(1e-12); + let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4); + sp.insert_anchor(seg, t) + }); + } + let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) }); + out.push(last); + Some(out) + } + + /// Spreading keys along a spine measured once and kept up to date gives the same anchors and + /// the same spine as measuring it again for each key: on open and closed polylines and curves, + /// zero-length segments, keys exactly on anchors, keys within, at and just past a hundredth of + /// a point from an anchor, a closed curve whose keys go round past its first anchor, many keys + /// in one segment, and paths with no segment. The closed spines open as [`pin_spine`] opens + /// them. + #[test] + fn spreading_keys_matches_measuring_the_spine_for_each_key() { + let p = Point::new; + let bow = SubPath::new( + vec![Anchor::smooth(p(0.0, 0.0), p(50.0, 50.0)), Anchor::smooth(p(100.0, 0.0), p(150.0, -50.0)), Anchor::corner(p(200.0, 0.0))], + false, + ); + let mut spines = vec![ + PathData::default(), + PathData::single(SubPath::default()), + PathData::single(SubPath::polyline(&[p(5.0, 5.0)], false)), + PathData::single(SubPath::polyline(&[p(5.0, 5.0), p(5.0, 5.0)], false)), + PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(100.0, 0.0)], false)), + PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(100.0, 0.0)], true)), + PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(10.0, 0.0), p(20.0, 0.0), p(30.0, 0.0)], false)), + PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(30.0, 5.0), p(30.0, 5.0)], false)), + PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0)], true)), + PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0), p(0.0, 0.0)], true)), + PathData::single(bow.clone()), + PathData::single(SubPath { closed: true, ..bow }), + shapes::ellipse(Rect::new(0.0, 0.0, 120.0, 80.0)), + ]; + // An anchor near where the middle of three keys goes, 10 along a spine 20 long. + for d in [0.0, 0.005, 0.0099, 0.01, 0.0101, 0.02] { + for x in [10.0 - d, 10.0 + d] { + spines.push(PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(x, 0.0), p(20.0, 0.0)], false))); + } + } + let mut compared = 0; + for spine in &spines { + for k in (0..=12).chain([17, 50, 200]) { + let (mut a, mut b) = (spine.clone(), spine.clone()); + let (ka, kb) = (spread_anchors(&mut a, k), rebuilt_spread_anchors(&mut b, k)); + assert_eq!(ka, kb, "{k} keys on {spine:?}"); + assert_eq!(a, b, "{k} keys on {spine:?}"); + if let (Some(mut ka), Some(mut kb)) = (ka, kb) { + let (mut sa, mut sb) = (a.subpaths[0].clone(), b.subpaths[0].clone()); + open_at_start(&mut sa, &mut ka); + open_at_start(&mut sb, &mut kb); + assert_eq!((sa, ka), (sb, kb), "{k} keys on {spine:?}, opened"); + compared += 1; + } + } + } + assert!(compared > 200, "{compared} spreads compared"); + } + + /// Spreading 1,000 keys along a spine of 4,096 segments flattens the spine once and then only + /// the two halves of each split segment, finds each key's segment in a few comparisons, and + /// measures about as many segments as the spine ends up with, not the whole spine per key. + #[test] + fn spreading_keys_measures_the_spine_once() { + let pts: Vec = (0..=4096).map(|i| Point::new(i as f64, if i % 2 == 0 { 0.0 } else { 1.0 })).collect(); + let mut path = PathData::single(SubPath::polyline(&pts, false)); + let (mut whole, mut segments, mut measured, mut compared) = (0, 0, 0, 0); + let ka = spread_anchors_with(&mut path, 1000, |w| match w { + SpreadWork::WholePath => whole += 1, + SpreadWork::Segment => segments += 1, + SpreadWork::Measure => measured += 1, + SpreadWork::Compare => compared += 1, + }) + .unwrap(); + assert_eq!(ka.len(), 1000); + let n = path.subpaths[0].segment_count(); + assert!(n > 4096 + 900, "{n} segments"); + assert_eq!(whole, 1); + assert!(segments <= 2 * 998, "{segments} segments flattened"); + assert!(compared <= 998 * 14, "{compared} comparisons"); + assert!(measured <= 4096 + n + 3 * 1000, "{measured} segments measured for {n}"); + let mut reference = PathData::single(SubPath::polyline(&pts, false)); + assert_eq!(rebuilt_spread_anchors(&mut reference, 1000), Some(ka)); + assert_eq!(reference, path); + } + + /// A spine so large that its length overflows spreads no keys, and is left as it was, rather + /// than getting an anchor split at a parameter that is not a number. Two keys, which split + /// nothing, get no anchors either. + #[test] + fn spreading_keys_along_a_spine_whose_length_overflows_changes_nothing() { + let p = Point::new; + for closed in [false, true] { + for k in [2, 3, 5] { + let spine = PathData::single(SubPath::polyline(&[p(-1e308, 0.0), p(0.0, 0.0), p(1e308, 0.0)], closed)); + let mut path = spine.clone(); + assert_eq!(spread_anchors(&mut path, k), None, "closed {closed}, {k} keys"); + assert_eq!(path, spine); + assert!(path.subpaths[0].anchors.iter().all(|a| a.p.x.is_finite() && a.h_in.x.is_finite() && a.h_out.x.is_finite())); + } + } + } + + /// When a later arc length has no anchor (here one that is not finite, as when a spine's length + /// overflows only after earlier splits), the splits already made are dropped with it. + #[test] + fn a_failed_anchor_keeps_none_of_the_earlier_splits() { + let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false); + for bad in [f64::NAN, f64::INFINITY] { + let mut split = sp.clone(); + let lengths = SpreadLengths::new(&split, &mut |_| {}); + assert_eq!(anchors_at_lengths(&mut split, lengths, [25.0, 50.0, bad], &mut |_| {}), None); + assert_eq!(split, sp); + } + let mut split = sp.clone(); + let lengths = SpreadLengths::new(&split, &mut |_| {}); + assert_eq!(anchors_at_lengths(&mut split, lengths, [25.0, 50.0], &mut |_| {}), Some(vec![1, 2])); + assert_eq!(split.anchors.len(), 4); + } + #[test] fn easing_keeps_the_ends_and_stays_monotone() { use crate::live::{BlendEase, BlendEaseType as K}; diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index 491b79f158..d920c51332 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -3,6 +3,7 @@ //! the ruler origin and the objects an edit touches), so a file holding something beyond it could be //! opened but not edited: one object, artboard or guide out there made every move fail. +use std::collections::BTreeSet; use std::sync::Arc; use vectorcraft_geom::{Point, Rect}; @@ -72,27 +73,41 @@ fn clipping(n: &Node) -> Option { } } -/// Does `n`, or any object in it, reach beyond the canvas? A container is looked into object by -/// object: its own bounds leave out a layer's hidden objects and, when it clips, all but its -/// clipping path. -fn reaches_beyond(n: &Node) -> bool { - match clipping(n) { - None => beyond(n), - Some(_) => n.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))), +/// The objects of a layer or group that a walk picked, by index in order, each with what was +/// picked inside it (nothing for an object that is not a container). +#[derive(Debug, Default)] +struct Picked(Vec<(usize, Picked)>); + +/// What in `n` reaches beyond the canvas, by `far`, or `None` when nothing does. A container is +/// looked into object by object: its own bounds leave out a layer's hidden objects and, when it +/// clips, all but its clipping path. Each object is measured once, and the objects a clipping +/// path beyond the canvas clips are not measured: they go with it (see [`clips_beyond`]). +fn reach(n: &Node, far: &impl Fn(&Node) -> bool) -> Option { + let Some(clip) = clipping(n) else { return far(n).then(Picked::default) }; + let mut inner = Vec::new(); + for (i, c) in n.children().into_iter().flatten().enumerate() { + if let Some(p) = reach(c, far) { + inner.push((i, p)); + if clip && i == 0 { + break; + } + } } + (!inner.is_empty()).then_some(Picked(inner)) } -/// Does `n` clip by a path that reaches beyond the canvas? Then the path and the objects it clips -/// go together, so no other object becomes the clipping path. -fn clips_beyond(n: &Node) -> bool { - clipping(n) == Some(true) && n.children().and_then(|c| c.first()).is_some_and(|c| reaches_beyond(c)) +/// Does `n`, whose objects beyond the canvas are `found`, clip by a path that reaches beyond it? +/// Then the path and the objects it clips go together, so no other object becomes the clipping +/// path. +fn clips_beyond(n: &Node, found: &Picked) -> bool { + clipping(n) == Some(true) && found.0.first().is_some_and(|(i, _)| *i == 0) } /// Add the ids of `n` and the layers in it, if it is a layer, to `layers`: their layer guides go /// with them. -fn layers_in(n: &Node, layers: &mut Vec) { +fn layers_in(n: &Node, layers: &mut BTreeSet) { if matches!(n.kind, NodeKind::Layer { .. }) { - layers.push(n.id); + layers.insert(n.id); } if clipping(n).is_some() { for c in n.children().into_iter().flatten() { @@ -109,22 +124,22 @@ fn guides_in(n: &Node) -> usize { } } -/// Remove the objects in `node` that reach beyond the canvas: a layer or group keeps those of its -/// objects within it, unless its clipping path reaches beyond (see [`clips_beyond`]); anything -/// else goes whole. Only containers holding such an object are copied. Recursion follows the -/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`, and -/// the ruler guides they held to `guides`. -fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut Vec, guides: &mut usize) { - if !node.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))) { - return; - } +/// Remove the objects in `node` that reach beyond the canvas, which [`reach`] `found`: a layer or +/// group keeps those of its objects within it, unless its clipping path reaches beyond (see +/// [`clips_beyond`]); anything else goes whole. Only containers holding such an object are +/// copied, and only those are visited again. Recursion follows the nesting, which +/// [`Document::limit_depth`] bounds. The layers removed are added to `layers`, and the ruler +/// guides they held to `guides`. +fn drop_children_beyond(node: &mut Arc, found: Picked, dropped: &mut usize, layers: &mut BTreeSet, guides: &mut usize) { let Some(children) = Arc::make_mut(node).children_mut() else { return }; + let mut found = found.0.into_iter().peekable(); + let mut next = 0; children.retain_mut(|c| { - if !reaches_beyond(c) { - return true; - } - if clipping(c).is_some() && !clips_beyond(c) { - drop_children_beyond(c, dropped, layers, guides); + let at = next; + next += 1; + let Some((_, inner)) = found.next_if(|(i, _)| *i == at) else { return true }; + if clipping(c).is_some() && !clips_beyond(c, &inner) { + drop_children_beyond(c, inner, dropped, layers, guides); return true; } layers_in(c, layers); @@ -134,21 +149,49 @@ fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut }); } +/// Is `id` unset, or not one of `ids`? The check takes time logarithmic in the number of ids. +fn not_in(id: Option, ids: &BTreeSet) -> bool { + id.is_none_or(|i| !ids.contains(&i)) +} + /// Remove the ruler guides of layer `layer` and of its sublayers that `out` picks, adding how many -/// to `dropped`. Only layers holding one are copied. +/// to `dropped`. `out` looks at each object in the layers once. Only layers holding one are +/// copied. fn drop_guides(layer: &mut Arc, out: &impl Fn(&Node) -> bool, dropped: &mut usize) { - fn holds(n: &Node, out: &impl Fn(&Node) -> bool) -> bool { - n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| out(c) || holds(c, out))) + /// The objects of `n`, if it is a layer, that `out` picks (with nothing inside), and its + /// sublayers holding one (with what they hold), or `None` when it holds none. + fn holds(n: &Node, out: &impl Fn(&Node) -> bool) -> Option { + if !n.is_layer() { + return None; + } + let inner: Vec<(usize, Picked)> = n + .children() + .into_iter() + .flatten() + .enumerate() + .filter_map(|(i, c)| if out(c) { Some((i, Picked::default())) } else { holds(c, out).map(|p| (i, p)) }) + .collect(); + (!inner.is_empty()).then_some(Picked(inner)) } - if !holds(layer, out) { - return; + fn drop(layer: &mut Arc, held: Picked, dropped: &mut usize) { + let Some(children) = Arc::make_mut(layer).children_mut() else { return }; + let mut held = held.0.into_iter().peekable(); + let mut next = 0; + children.retain_mut(|c| { + let at = next; + next += 1; + let Some((_, inner)) = held.next_if(|(i, _)| *i == at) else { return true }; + // A sublayer holds something; an object picked itself has nothing inside. + if inner.0.is_empty() { + *dropped += 1; + return false; + } + drop(c, inner, dropped); + true + }); } - let Some(children) = Arc::make_mut(layer).children_mut() else { return }; - let before = children.len(); - children.retain(|c| !out(c)); - *dropped += before - children.len(); - for c in children.iter_mut().filter(|c| c.is_layer()) { - drop_guides(c, out, dropped); + if let Some(held) = holds(layer, out) { + drop(layer, held, dropped); } } @@ -162,9 +205,10 @@ impl Document { /// whose artboards were all removed has none left. pub fn drop_beyond_canvas(&mut self) -> BeyondCanvas { let mut gone = BeyondCanvas::default(); - let mut layers = vec![]; + let mut layers = BTreeSet::new(); for layer in &mut self.layers { - if clips_beyond(layer) { + let Some(found) = reach(layer, &beyond) else { continue }; + if clips_beyond(layer, &found) { let layer = Arc::make_mut(layer); if let NodeKind::Layer { children, clip, .. } = &mut layer.kind { for c in children.iter() { @@ -177,7 +221,7 @@ impl Document { *clip = false; } } else { - drop_children_beyond(layer, &mut gone.objects, &mut layers, &mut gone.guides); + drop_children_beyond(layer, found, &mut gone.objects, &mut layers, &mut gone.guides); } } if gone.objects > 0 { @@ -192,13 +236,13 @@ impl Document { self.retire_artboard_id(*id); } gone.artboards = removed.len(); + let removed: BTreeSet = removed.into_iter().collect(); // The saved list, when the guides are not nodes yet ([`Document::guides_into_nodes`]). let guides = self.saved_guides.len(); - self.saved_guides - .retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); + self.saved_guides.retain(|g| in_range(g.pos) && not_in(g.artboard, &removed) && not_in(g.layer, &layers)); gone.guides += guides - self.saved_guides.len(); let out = |n: &Node| match n.kind { - NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || artboard.is_some_and(|a| removed.contains(&a)), + NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || !not_in(artboard, &removed), _ => false, }; for layer in &mut self.layers { @@ -392,4 +436,153 @@ mod tests { assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::>(), [(2, vec![NodeId(10)])]); assert_eq!(d.variables.bindings.keys().copied().collect::>(), [NodeId(10)]); } + + #[test] + fn guides_of_many_removed_artboards_and_layers_go_and_the_rest_keep_their_order() { + let probe = Document::new(1.0, 1.0); + // Sublayers 40 to 43 clip by a path beyond the canvas, so they go whole; 50 stays. + let far: Vec> = (40..44).map(|id| sublayer(&probe, id, vec![rect(id * 10, 1e17), rect(id * 10 + 1, 0.0)], true)).collect(); + let near = sublayer(&probe, 50, vec![rect(500, 0.0)], false); + let mut d = doc(far.into_iter().chain([near, rect(10, 0.0)]).collect()); + let kept = d.artboards[0].id; + for id in 9..13 { + d.artboards.push(Artboard { id, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() }); + } + let top = d.layers[0].id; + let guide = |pos: f64, artboard: Option, layer: Option| Guide { artboard, layer, ..Guide::new(false, pos) }; + d.saved_guides = vec![ + guide(1.0, Some(9), None), + guide(2.0, None, None), + guide(3.0, Some(kept), Some(NodeId(41))), + guide(4.0, Some(kept), None), + guide(5.0, Some(12), None), + guide(6.0, None, Some(NodeId(50))), + guide(7.0, None, Some(NodeId(43))), + guide(8.0, Some(10), Some(top)), + guide(9.0, None, Some(top)), + ]; + d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(42), NodeId(10), NodeId(500)] }].into(); + let mut nodes = d.clone(); + assert_eq!(nodes.guides_into_nodes(), 9); + let gone = d.drop_beyond_canvas(); + assert_eq!(gone, BeyondCanvas { objects: 4, artboards: 4, guides: 5, ruler_origin: false }); + assert_eq!(d.artboards.iter().map(|a| a.id).collect::>(), [kept]); + let left: Vec = d.saved_guides.iter().map(|g| g.pos).collect(); + assert_eq!(left, [2.0, 4.0, 6.0, 9.0], "the guides that stay keep their order"); + assert_eq!(d.saved_selections[0].objects, [NodeId(10), NodeId(500)]); + assert_eq!(nodes.drop_beyond_canvas(), gone, "guide nodes go as saved guides do"); + let mut left: Vec = nodes.ruler_guides().iter().map(|g| g.pos).collect(); + left.sort_by(f64::total_cmp); + assert_eq!(left, [2.0, 4.0, 6.0, 9.0]); + assert_eq!(nodes.saved_selections, d.saved_selections); + } + + thread_local! { + static COMPARISONS: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + /// An id that counts how often it is compared. + #[derive(Debug)] + struct Counted(u32); + + impl Ord for Counted { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + COMPARISONS.with(|c| c.set(c.get() + 1)); + self.0.cmp(&other.0) + } + } + + impl PartialOrd for Counted { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } + } + + impl PartialEq for Counted { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == std::cmp::Ordering::Equal + } + } + + impl Eq for Counted {} + + #[test] + fn a_guide_is_checked_without_scanning_every_removed_id() { + let per_guide = |removed: u32| { + let ids: BTreeSet = (0..removed).map(|i| Counted(i * 2)).collect(); + COMPARISONS.with(|c| c.set(0)); + // Guides on removed ids, on ids between them, on ids past them and on none. + let guides: Vec> = (0..64).map(|i| (i % 4 != 3).then(|| Counted(i * 31 % (removed * 2 + 8)))).collect(); + let kept = guides.into_iter().filter(|g| not_in(g.as_ref().map(|c| Counted(c.0)), &ids)).count(); + assert!(kept > 0 && kept < 64); + COMPARISONS.with(|c| c.get()) as f64 / 64.0 + }; + let small = per_guide(16); + let large = per_guide(16_384); + // A sorted lookup grows with the depth of the tree; a scan of 16,384 ids compares thousands per guide. + assert!(large < small * 32.0 && large < 256.0, "{small} comparisons per guide among 16 ids, {large} among 16,384"); + } + + #[test] + fn each_object_is_measured_once_however_deep_the_nesting() { + // 32 groups, each inside the one before and holding an object within the canvas; the + // innermost also holds one beyond it. Beside them, a group with nothing beyond and a clip + // group whose clipping path reaches beyond. + let mut chain = Arc::new(Node::group(NodeId(100), vec![rect(200, 0.0), rect(201, 1e17)])); + for depth in 1..32 { + chain = Arc::new(Node::group(NodeId(100 + depth), vec![rect(300 + depth, 0.0), chain])); + } + let still = Arc::new(Node::group(NodeId(400), vec![rect(401, 0.0), rect(402, 5.0)])); + let clip = Arc::new(Node::new(NodeId(600), NodeKind::Group { children: vec![rect(601, 1e17), rect(602, 0.0), rect(603, 0.0)], clip: true })); + let mut d = doc(vec![chain, Arc::clone(&still), clip]); + let mut whole = d.clone(); + let measured = std::cell::Cell::new(0_usize); + let far = |n: &Node| { + measured.set(measured.get() + 1); + beyond(n) + }; + let layer = &mut d.layers[0]; + let found = reach(layer, &far).unwrap(); + assert_eq!( + measured.get(), + 36, + "the 33 objects in the groups, the 2 beside them and the clipping path, each measured once; not what the path clips" + ); + assert!(!clips_beyond(layer, &found)); + let (mut dropped, mut layers, mut guides) = (0, BTreeSet::new(), 0); + drop_children_beyond(layer, found, &mut dropped, &mut layers, &mut guides); + assert_eq!(measured.get(), 36, "removing measures nothing again"); + assert_eq!((dropped, layers.len(), guides), (2, 0, 0)); + assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still)), "a group with nothing beyond is not copied"); + assert_eq!(whole.drop_beyond_canvas(), BeyondCanvas { objects: 2, ..Default::default() }); + assert_eq!(ids(&whole), ids(&d)); + assert!(!ids(&d).contains(&201) && !ids(&d).contains(&600) && ids(&d).contains(&200) && ids(&d).contains(&331)); + } + + #[test] + fn each_object_is_looked_at_once_when_guides_go_from_nested_layers() { + let guide = |id: u64, pos: f64| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: false, pos, artboard: None })); + let probe = Document::new(1.0, 1.0); + // 32 sublayers, each inside the one before, each holding a guide within the canvas and one + // beyond it. Beside them, a sublayer with nothing to remove. + let mut chain = sublayer(&probe, 100, vec![guide(200, 5.0), guide(201, 1e17)], false); + for depth in 1..32 { + chain = sublayer(&probe, 100 + depth, vec![guide(300 + depth * 2, 5.0), guide(301 + depth * 2, 1e17), chain], false); + } + let still = sublayer(&probe, 500, vec![guide(501, 5.0), rect(502, 0.0)], false); + let mut d = doc(vec![chain, Arc::clone(&still)]); + let looked = std::cell::Cell::new(0_usize); + let out = |n: &Node| { + looked.set(looked.get() + 1); + matches!(n.kind, NodeKind::RulerGuide { pos, .. } if !in_range(pos)) + }; + let mut dropped = 0; + drop_guides(&mut d.layers[0], &out, &mut dropped); + assert_eq!(dropped, 32); + assert_eq!(looked.get(), 2 + 2 + 31 * 3 + 2, "each object in the layers is looked at once"); + assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still)), "a sublayer with nothing to remove is not copied"); + let left = d.ruler_guides(); + assert_eq!(left.len(), 33); + assert!(left.iter().all(|g| g.pos == 5.0)); + } } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 057a9df6a6..418e454b92 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -78,6 +78,7 @@ impl Document { units_from_file: _, index: _, guide_cache: _, + inline_cycles: _, } = self; *version == other.version && *title == other.title @@ -262,6 +263,7 @@ impl Document { units_from_file: false, index: Default::default(), guide_cache: Default::default(), + inline_cycles: Default::default(), }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index f3b655d76a..43074a4e80 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -166,6 +166,16 @@ pub struct LabelStyles { /// The anti-aliasing method the legend labels keep once set. `None` draws them Sharp. #[serde(skip_serializing_if = "Option::is_none")] pub legend_anti_alias: Option, + /// The object fill and stroke rows the value axis numbers keep once set with the Appearance controls ([`LabelRows`]). + /// `None`: no rows, opaque and Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub values_rows: Option, + /// The object fill and stroke rows the category labels keep, as [`Self::values_rows`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub categories_rows: Option, + /// The object fill and stroke rows the legend labels keep, as [`Self::values_rows`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub legend_rows: Option, } impl LabelStyles { @@ -355,6 +365,22 @@ pub struct SeriesPaint { pub blend: Option, } +/// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode +/// of a label's topmost visible fill and stroke row (an absent paint: no row of that kind), the label's object opacity and +/// blend mode, and where the two rows paint relative to each other and to the characters. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LabelRows { + #[serde(flatten)] + pub paint: SeriesPaint, + /// How many of the kept rows paint below the characters, counted from the bottom. Absent: none. + #[serde(skip_serializing_if = "Option::is_none")] + pub below: Option, + /// The stroke row paints below the fill row. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stroke_under: bool, +} + /// Graph data and options. `rows` are categories (one per row of the Graph Data window), each /// holding one value per series; `series` are the legend labels (the first row of the window). /// Scatter graphs read each series as (y, x) column pairs, like Illustrator. diff --git a/crates/doc/src/inline.rs b/crates/doc/src/inline.rs index c5bf1972d8..a8e3955015 100644 --- a/crates/doc/src/inline.rs +++ b/crates/doc/src/inline.rs @@ -2,10 +2,13 @@ //! from the document's symbols, which the text layout needs to size and place it. use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock, Weak}; use vectorcraft_geom::{Affine, Rect}; -use crate::{Document, NodeId, NodeKind, TextObject}; +use crate::{Document, Node, NodeId, NodeKind, Symbol, TextObject}; /// The document key under which the app records each symbol's natural size (`{name: [w, h]}`). pub const SYMBOL_SIZES: &str = "symbolSizes"; @@ -39,9 +42,27 @@ impl Document { usable(Some(self.symbol_natural_xf(name).transform_rect_bbox(b))) } + /// The art bounds an inline graphic of the symbol named `name` takes: [`Self::symbol_art_bounds`], + /// or `None` (an empty slot) when the symbol's art shows the symbol itself inline, directly or + /// through the inline graphics in other symbols' art, so that its size would depend on itself. + pub fn inline_art_bounds(&self, name: &str) -> Option { + if self.inline_cycles().contains(name) { None } else { self.symbol_art_bounds(name) } + } + + /// The names of the symbols that reach themselves through the inline graphics in symbol art, + /// found once while the symbols stay as they are. + fn inline_cycles(&self) -> Arc> { + if let Some(found) = self.inline_cycles.get(&self.symbols) { + return found; + } + let found = Arc::new(InlineGraph::of(self).cyclic); + self.inline_cycles.set(&self.symbols, &found); + found + } + /// Does `t` have inline graphics whose resolved bounds differ from the document's symbols? fn inline_stale(&self, t: &TextObject) -> bool { - t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != self.symbol_art_bounds(&a.symbol)) + stale_by(t, &|n| self.inline_art_bounds(n)) } /// `t` with its inline graphics' art bounds resolved from this document's symbols (borrowed @@ -56,54 +77,421 @@ impl Document { Cow::Owned(t) } - /// Fill in the art bounds of `t`'s inline graphics; returns whether any changed (the text's - /// cached layout bounds are then cleared). + /// Fill in the art bounds of `t`'s inline graphics ([`Self::inline_art_bounds`]); returns + /// whether any changed (the text's cached layout is then cleared). pub fn resolve_text_inline(&self, t: &mut TextObject) -> bool { - let mut changed = false; - for r in &mut t.runs { - if let Some(a) = &mut r.inline { - let b = self.symbol_art_bounds(&a.symbol); - if a.bounds != b { - a.bounds = b; - changed = true; - } - } - } - if changed { - t.cached_bounds = None; - t.cached_ink = None; - } - changed + resolve_runs(t, &|n| self.inline_art_bounds(n)) } - /// Resolve the inline graphics of every text object in the layers (after opening a document - /// or changing its symbols). Returns the ids of the texts that changed: their cached layout - /// bounds were cleared and need recomputing. + /// Resolve the inline graphics of every text object in the document (after opening a document + /// or changing its symbols), without laying the type out again: see + /// [`Self::resolve_inline_art_with`]. pub fn resolve_inline_art(&mut self) -> Vec { - let mut stale = vec![]; - self.walk(|n| { - if let NodeKind::Text(t) = &n.kind - && t.runs.iter().any(|r| r.inline.is_some()) - && self.inline_stale(t) - { - stale.push(n.id); - } - }); + self.resolve_inline_art_with(&mut |_| {}) + } + + /// Resolve the inline graphics of every text object in the document: in the layers, the art + /// of symbols, graph designs and pattern tiles, and the opacity masks and live objects in + /// them. Each text whose inline graphics changed has its cached layout cleared and is passed + /// to `layout` to be laid out again. The symbols are resolved first, each after the symbols its + /// inline graphics show, so a symbol's art is measured with its type already laid out; a + /// symbol that shows itself inline ([`Self::inline_art_bounds`]) is shown as an empty slot. + /// Returns the ids of the texts in the layers that changed. + pub fn resolve_inline_art_with(&mut self, layout: &mut dyn FnMut(&mut TextObject)) -> Vec { let mut out = vec![]; - for id in stale { - let Some(mut t) = self.node(id).and_then(|n| match &n.kind { - NodeKind::Text(t) => Some(t.clone()), - _ => None, - }) else { - continue; - }; - if self.resolve_text_inline(&mut t) - && let Some(n) = self.node_mut(id) - { - n.kind = NodeKind::Text(t); - out.push(id); + let mut unused = vec![]; + let graph = InlineGraph::of(self); + let measured = Measured::default(); + for &i in &graph.order { + let mut art = self.symbols[i].art.clone(); + if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) { + self.symbols[i].art = art; + } + } + for i in 0..self.layers.len() { + let mut layer = self.layers[i].clone(); + if resolve_tree(&mut layer, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut out) { + self.layers[i] = layer; + } + } + for i in 0..self.graph_designs.len() { + let mut art = self.graph_designs[i].art.clone(); + if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) { + self.graph_designs[i].art = art; + } + } + for i in 0..self.patterns.len() { + for j in 0..self.patterns[i].art.len() { + let mut art = self.patterns[i].art[j].clone(); + if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) { + self.patterns[i].art[j] = art; + } } } out } } + +/// Whether `t` has inline graphics whose bounds differ from what `bounds` gives their symbols. +fn stale_by(t: &TextObject, bounds: &dyn Fn(&str) -> Option) -> bool { + t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != bounds(&a.symbol)) +} + +/// Gives `t`'s inline graphics the bounds `bounds` gives their symbols; returns whether any changed +/// (the text's cached layout is then cleared). +fn resolve_runs(t: &mut TextObject, bounds: &dyn Fn(&str) -> Option) -> bool { + let mut changed = false; + for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { + let b = bounds(&a.symbol); + if a.bounds != b { + a.bounds = b; + changed = true; + } + } + if changed { + t.cached_bounds = None; + t.cached_baselines.clear(); + t.cached_ink = None; + } + changed +} + +/// Resolves the inline graphics of the type in tree `n` (opacity mask art included) with +/// `bounds`, passing each text that changed to `layout` and adding its id to `changed`; returns +/// whether the tree changed. Each node is visited once, and only the nodes on the way to changed +/// type are copied. +fn resolve_tree( + n: &mut Arc, + bounds: &dyn Fn(&str) -> Option, + layout: &mut dyn FnMut(&mut TextObject), + changed: &mut Vec, +) -> bool { + let mut copied = false; + if matches!(&n.kind, NodeKind::Text(t) if stale_by(t, bounds)) { + let node = Arc::make_mut(n); + if let NodeKind::Text(t) = &mut node.kind + && resolve_runs(t, bounds) + { + layout(t); + changed.push(node.id); + } + copied = true; + } + let mask = n.extras().mask.as_deref().and_then(|m| { + let mut art = m.art.clone(); + resolve_tree(&mut art, bounds, layout, changed).then_some(art) + }); + let mut kids = vec![]; + for (i, c) in n.children().into_iter().flatten().enumerate() { + let mut c = c.clone(); + if resolve_tree(&mut c, bounds, layout, changed) { + kids.push((i, c)); + } + } + if mask.is_none() && kids.is_empty() { + return copied; + } + let node = Arc::make_mut(n); + if let Some(art) = mask + && let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_deref_mut()) + { + m.art = art; + } + if let Some(ch) = node.children_mut() { + for (i, c) in kids { + ch[i] = c; + } + } + true +} + +/// The art bounds of each symbol, measured once in a pass of [`Document::resolve_inline_art_with`]. +/// A symbol is first asked for after its own type is resolved (the pass goes in +/// [`InlineGraph::order`]), so its measurement stays current for the rest of the pass. +#[derive(Default)] +struct Measured(RefCell>>); + +impl Measured { + /// The bounds kept for `name`, or `measure`'s, kept for later. + fn get(&self, name: &str, measure: impl FnOnce() -> Option) -> Option { + if let Some(&b) = self.0.borrow().get(name) { + return b; + } + let b = measure(); + self.0.borrow_mut().insert(name.to_owned(), b); + b + } +} + +/// The names of the symbols the inline graphics in tree `n` show (opacity mask art included). +fn inline_symbols(n: &Node) -> Vec { + fn collect(n: &Node, out: &mut Vec) { + if let NodeKind::Text(t) = &n.kind { + out.extend(t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))); + } + if let Some(m) = n.extras().mask.as_deref() { + collect(&m.art, out); + } + for c in n.children().into_iter().flatten() { + collect(c, out); + } + } + let mut out = vec![]; + collect(n, &mut out); + out +} + +/// Each symbol name of `d` → the index of the first symbol of that name (the one a name finds). +fn symbol_index(d: &Document) -> HashMap<&str, usize> { + let mut index = HashMap::with_capacity(d.symbols.len()); + for (i, s) in d.symbols.iter().enumerate() { + index.entry(s.name.as_str()).or_insert(i); + } + index +} + +/// The symbols of a document as their inline graphics depend on each other. +struct InlineGraph { + /// Every symbol index, each after the symbols its art shows inline (except along a cycle). + order: Vec, + /// The names of the symbols that reach themselves through inline graphics. + cyclic: HashSet, +} + +impl InlineGraph { + /// The graph of `d`'s symbols: each symbol's art is walked once, and the order and cycles are + /// found in time linear in the symbols and their inline references (Tarjan's strongly + /// connected components, without recursion). + fn of(d: &Document) -> Self { + let index = symbol_index(d); + let edges: Vec> = d + .symbols + .iter() + .map(|s| { + let mut e: Vec = inline_symbols(&s.art).iter().filter_map(|n| index.get(n.as_str()).copied()).collect(); + e.sort_unstable(); + e.dedup(); + e + }) + .collect(); + let (order, cyclic) = components(&edges); + let cyclic = order.iter().filter(|&&i| cyclic[i]).map(|&i| d.symbols[i].name.clone()).collect(); + Self { order, cyclic } + } + + /// [`Document::inline_art_bounds`] with the cycles found in this graph. + fn bounds(&self, d: &Document, name: &str) -> Option { + if self.cyclic.contains(name) { None } else { d.symbol_art_bounds(name) } + } +} + +/// The nodes of the graph `edges` (node → the nodes it depends on), each after the nodes it +/// depends on except within a cycle, and whether each node is on a cycle (a component of more than +/// one node, or an edge to itself). +fn components(edges: &[Vec]) -> (Vec, Vec) { + const UNSEEN: usize = usize::MAX; + let n = edges.len(); + let (mut index, mut low, mut on_stack) = (vec![UNSEEN; n], vec![0; n], vec![false; n]); + let (mut stack, mut order, mut cyclic) = (vec![], Vec::with_capacity(n), vec![false; n]); + let mut next = 0; + // The nodes being visited, each with the position of its next edge. + let mut calls: Vec<(usize, usize)> = vec![]; + for root in 0..n { + if index[root] != UNSEEN { + continue; + } + index[root] = next; + low[root] = next; + next += 1; + stack.push(root); + on_stack[root] = true; + calls.push((root, 0)); + while let Some(top) = calls.last_mut() { + let v = top.0; + if let Some(&w) = edges[v].get(top.1) { + top.1 += 1; + if index[w] == UNSEEN { + index[w] = next; + low[w] = next; + next += 1; + stack.push(w); + on_stack[w] = true; + calls.push((w, 0)); + } else if on_stack[w] { + low[v] = low[v].min(index[w]); + } + continue; + } + calls.pop(); + if let Some(&(u, _)) = calls.last() { + low[u] = low[u].min(low[v]); + } + if low[v] == index[v] { + let start = order.len(); + while let Some(w) = stack.pop() { + on_stack[w] = false; + order.push(w); + if w == v { + break; + } + } + if order.len() - start > 1 || edges[v].contains(&v) { + for &w in &order[start..] { + cyclic[w] = true; + } + } + } + } + } + (order, cyclic) +} + +/// The symbols [`Document::inline_cycles`] last found, with the name of each symbol and a weak +/// pointer to its art. While a weak pointer to an art's allocation is held, nothing else can be at +/// that address, and the art can't be changed in place (`Arc::get_mut` refuses it and +/// `Arc::make_mut` moves it), so any edit inside a symbol's art, and any symbol added, removed, +/// renamed or replaced, gives symbols the cache does not match. Clones of a document share it. +#[derive(Default)] +pub(crate) struct InlineCycles(RwLock>>); + +/// The name and art of each symbol the cycles were found in, and the names of the symbols on one. +type Found = (Vec<(String, Weak)>, Arc>); + +impl InlineCycles { + /// The cached names, when they were found in `symbols`. + fn get(&self, symbols: &[Symbol]) -> Option>> { + let g = self.0.read().ok()?; + let (from, found) = &**g.as_ref()?; + let same = from.len() == symbols.len() + && from.iter().zip(symbols).all(|((name, art), s)| *name == s.name && std::ptr::eq(art.as_ptr(), Arc::as_ptr(&s.art))); + same.then(|| found.clone()) + } + + /// Keep `found`, found in `symbols`. + fn set(&self, symbols: &[Symbol], found: &Arc>) { + if let Ok(mut g) = self.0.write() { + *g = Some(Arc::new((symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect(), found.clone()))); + } + } +} + +impl Clone for InlineCycles { + fn clone(&self) -> Self { + InlineCycles(RwLock::new(self.0.read().ok().and_then(|g| g.clone()))) + } +} + +/// A cache, not document content: two documents are equal whatever their caches hold. +impl PartialEq for InlineCycles { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for InlineCycles { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("InlineCycles") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The position of each node in `order`. + fn positions(order: &[usize]) -> Vec { + let mut at = vec![0; order.len()]; + for (p, &i) in order.iter().enumerate() { + at[i] = p; + } + at + } + + #[test] + fn components_put_dependencies_first_and_find_every_cycle_member() { + // 0 → 1 → 2 → 0 is a cycle, and 3, reached from 1, joins it through 2. + // 4 depends on 0 and is on no cycle; 5 shows itself. + let edges = vec![vec![1], vec![2, 3], vec![0], vec![2], vec![0], vec![5]]; + let (order, cyclic) = components(&edges); + let mut sorted = order.clone(); + sorted.sort_unstable(); + assert_eq!(sorted, [0, 1, 2, 3, 4, 5]); + assert_eq!(cyclic, [true, true, true, true, false, true]); + let at = positions(&order); + assert!(at[4] > at[0], "{order:?}"); + // A chain: each node after the one it depends on. + let (order, cyclic) = components(&[vec![], vec![0], vec![1]]); + assert_eq!(order, [0, 1, 2]); + assert!(cyclic.iter().all(|c| !c)); + } + + /// Type at the bottom of 2,000 nested groups is checked once, not once for each group above it, + /// and only the groups on the way to it are copied. + #[test] + fn resolving_deeply_nested_type_visits_each_node_once() { + use std::cell::Cell; + use vectorcraft_geom::Point; + + let mut t = TextObject::point(Point::ZERO, "Type", crate::CharStyle::default()); + t.runs.push(crate::TextRun::new("", crate::CharStyle::default())); + t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None }); + let depth = 2_000; + let mut tree = Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(t)))); + for i in 1..=depth { + let side = Arc::new(Node::group(NodeId(depth + i), vec![])); + tree = Arc::new(Node::group(NodeId(i), vec![side, tree])); + } + let before = tree.clone(); + let asked = Cell::new(0); + let r = Rect::new(0.0, 0.0, 10.0, 20.0); + let bounds = |_: &str| { + asked.set(asked.get() + 1); + Some(r) + }; + let (mut laid_out, mut changed) = (0, vec![]); + assert!(resolve_tree(&mut tree, &bounds, &mut |_| laid_out += 1, &mut changed)); + assert!(asked.get() <= 2, "bounds asked {} times for one inline graphic", asked.get()); + assert_eq!(laid_out, 1); + assert_eq!(changed, [NodeId(0)]); + // The bottom of the new tree holds the resolved type; the side groups are shared. + let (mut new, mut old) = (&tree, &before); + while let (Some(n), Some(o)) = (new.children(), old.children()) { + assert!(Arc::ptr_eq(&n[0], &o[0])); + (new, old) = (&n[1], &o[1]); + } + let NodeKind::Text(t) = &new.kind else { panic!("the type is at the bottom") }; + assert_eq!(t.runs[1].inline.as_ref().and_then(|a| a.bounds), Some(r)); + // Nothing left to change: the tree is kept and the type is checked once. + asked.set(0); + let kept = tree.clone(); + assert!(!resolve_tree(&mut tree, &bounds, &mut |_| laid_out += 1, &mut changed)); + assert!(Arc::ptr_eq(&tree, &kept)); + assert_eq!(asked.get(), 1); + } + + #[test] + fn a_symbol_is_measured_once_in_a_pass() { + let measured = Measured::default(); + let count = std::cell::Cell::new(0); + let measure = |b: Option| { + count.set(count.get() + 1); + b + }; + let r = Some(Rect::new(0.0, 0.0, 1.0, 1.0)); + for _ in 0..3 { + assert_eq!(measured.get("A", || measure(r)), r); + assert_eq!(measured.get("Missing", || measure(None)), None); + } + assert_eq!(count.get(), 2); + } + + #[test] + fn components_handle_a_long_chain_without_recursion() { + let n = 200_000; + let edges: Vec> = (0..n).map(|i| if i + 1 < n { vec![i + 1] } else { vec![] }).collect(); + let (order, cyclic) = components(&edges); + assert_eq!(order.first(), Some(&(n - 1))); + assert_eq!(order.last(), Some(&0)); + assert!(cyclic.iter().all(|c| !c)); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 63a2676f77..c51e099840 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -92,8 +92,8 @@ pub use compound_shape::ShapeMode; pub use corners::LiveCorners; pub use depth::{MAX_DEPTH, MAX_SYMBOL_NODES}; pub use graph::{ - ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, - PieSort, SeriesPaint, TickLength, ValueAxisSide, + ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelRows, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, + PiePosition, PieSort, SeriesPaint, TickLength, ValueAxisSide, }; pub use hit::{Hit, HitKind}; pub use links::{LinkInfo, PlacementOptions}; @@ -117,6 +117,7 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption pub use style_libs::StyleLibrary; pub use text::FontHeightReference; pub use text::GlyphChoice; +pub use text::WarichuAlign; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, @@ -362,6 +363,10 @@ pub struct Document { /// The ruler guides as [`Document::ruler_guides`] lists them: a cache, never saved. #[serde(skip)] guide_cache: document_guides::GuideCache, + /// The symbols that show themselves inline ([`Document::inline_art_bounds`]): a cache, never + /// saved. + #[serde(skip)] + inline_cycles: inline::InlineCycles, } fn ppi72() -> f64 { diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 3cf8312289..20d0623d4d 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1247,4 +1247,60 @@ mod tests { assert_eq!(m, before); } } + + /// The pieces of a mesh-warped image make a knockout group, each knocking out its triangle + /// (clipping path or opacity mask art); the group keeps the image's transparency. + #[test] + fn mesh_warp_image_pieces_knock_out_by_their_triangles() { + let im = crate::ImageObject { + key: "pic".into(), + width: 4, + height: 4, + xf: Affine::scale(10.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut n = Node::new(NodeId(3), NodeKind::Image(Box::new(im.clone()))); + n.opacity = 0.5; + n.blend = crate::color::BlendMode::Multiply; + let same = |p: Point| p; + for masked in [false, true] { + let g = mesh_warp_image(&n, &im, &same, 2, false, masked); + assert_eq!((g.knockout, g.opacity, g.blend, g.isolate), (crate::Knockout::On, 0.5, crate::color::BlendMode::Multiply, false)); + let NodeKind::Group { children, clip: false } = &g.kind else { panic!("a group") }; + assert_eq!(children.len(), 8); + for c in children { + assert!(c.extras().warp_piece); + let outline = c.knockout_outline().unwrap_or_else(|| panic!("masked {masked}: an outline")); + let NodeKind::Path { path, .. } = &outline.kind else { panic!("a path") }; + assert_eq!(path.anchor_count(), 3); + } + } + // A clip group or masked image that is not a piece knocks out by what it paints. + let mut plain = mesh_warp_image(&n, &im, &same, 1, false, false); + let NodeKind::Group { children, .. } = &mut plain.kind else { panic!("a group") }; + let piece = Arc::make_mut(&mut children[0]); + piece.extras_mut().warp_piece = false; + assert!(piece.knockout_outline().is_none()); + } + + /// More cells than the objects live art may make cut no pieces (the count overflowed). + #[test] + fn mesh_warp_image_past_the_budget_makes_an_empty_group() { + let im = crate::ImageObject { + key: "pic".into(), + width: 4, + height: 4, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let n = Node::new(NodeId(3), NodeKind::Image(Box::new(im.clone()))); + for cells in [usize::MAX, 1000] { + let g = mesh_warp_image(&n, &im, &|p| p, cells, false, false); + assert!(matches!(&g.kind, NodeKind::Group { children, .. } if children.is_empty()), "{cells}"); + } + } } diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index b8309f675b..d87d7a28c4 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -207,9 +207,21 @@ pub fn mesh_warp_cost(cells: usize) -> usize { /// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past /// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the /// color `n` colorizes a grayscale image with, and `n`'s color mask. The result is a group with `n`'s id, name, -/// transparency, opacity mask and appearance. +/// transparency, opacity mask and appearance. It is a knockout group and each piece is a +/// [`crate::NodeExtras::warp_piece`], so where pieces overlap only the top one draws and a +/// partly transparent image keeps its own alpha there. More cells than [`MAX_GENERATED`] objects +/// allow make an empty group. pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node { let k = cells.max(1); + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vec![], clip: false }; + g.knockout = crate::Knockout::On; + if let Some(e) = g.extras_if_any() { + e.colorize = None; + } + if mesh_warp_cost(k) > MAX_GENERATED { + return g; + } // A grayscale image's fill color colorizes each piece; the group of pieces takes none. let colorize = n.extras().colorize.clone(); // Each piece shows what the image shows: its color mask's masked pixels. @@ -243,22 +255,22 @@ pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, c // Preserve Shape Using Transparency: the triangle is the piece's alpha (a // white opacity mask) instead of its clipping path. let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); - pieces.push(Arc::new(image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true)))))); + let mut piece = image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true)))); + piece.extras_mut().warp_piece = true; + pieces.push(Arc::new(piece)); continue; } let mut clip = Node::path(n.id, shape, Appearance::default()); if let NodeKind::Path { clipping, .. } = &mut clip.kind { *clipping = true; } - pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); + let mut piece = Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }); + piece.extras_mut().warp_piece = true; + pieces.push(Arc::new(piece)); } } } - let mut g = n.clone(); g.kind = NodeKind::Group { children: pieces, clip: false }; - if let Some(e) = g.extras_if_any() { - e.colorize = None; - } g } diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index 7b130bf395..e13750457e 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -75,6 +75,10 @@ pub struct NodeExtras { /// Image Tools › Color Mask: the colors an image hides while its pixels stay as they are /// ([`crate::color_mask`]). pub color_mask: Option>, + /// A piece of an image a raster mesh warp cut up ([`crate::live::mesh_warp_image`]): in a + /// knockout group its knockout shape is the whole area of its triangle, not only where the + /// image is opaque ([`Node::knockout_outline`]). + pub warp_piece: bool, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -103,6 +107,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { envelope_edit_margin: None, pixel_aligned: false, color_mask: None, + warp_piece: false, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 415fc1cad1..2bb655236a 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -716,6 +716,37 @@ impl Node { out } + /// As a piece of a mesh-warped image ([`crate::NodeExtras::warp_piece`]), the art whose area is its + /// knockout shape at full coverage: the art of its opacity mask (on and not inverted), or the + /// clipping path of its clip group. `None` for other objects, whose knockout shape is what they + /// paint. + pub fn knockout_outline(&self) -> Option<&Arc> { + let e = self.extras(); + if !e.warp_piece { + return None; + } + if let Some(m) = e.mask.as_deref() { + return (!m.disabled && !m.invert).then_some(&m.art); + } + match &self.kind { + NodeKind::Group { children, clip: true } => children.first(), + _ => None, + } + } + + /// The image a piece of a mesh-warped image draws ([`Self::knockout_outline`]): the piece itself + /// when an opacity mask cuts it, else the image its clip group clips. `None` for other objects. + pub fn warp_piece_image(&self) -> Option<&Node> { + if !self.extras().warp_piece { + return None; + } + match &self.kind { + NodeKind::Image(_) => Some(self), + NodeKind::Group { children, clip: true } => children.get(1).map(|c| &**c).filter(|c| matches!(c.kind, NodeKind::Image(_))), + _ => None, + } + } + /// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest. /// A layer whose first child is a ruler guide holds no art, so a guide never clips it. pub fn clips(&self) -> bool { diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 335eb6312e..9981514879 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -23,6 +23,7 @@ fn extras_are_written_flat_in_order() { e.shape_mode = crate::ShapeMode::Exclude; e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]); e.pixel_aligned = true; + e.warp_piece = true; } /// The top-level keys of a JSON object, in the order written. struct Keys(Vec); @@ -69,10 +70,12 @@ fn extras_are_written_flat_in_order() { "shape_mode", "envelope_edit_margin", "pixel_aligned", + "warp_piece", ]; assert_eq!(keys, order); assert_eq!(v["shape_mode"], "exclude"); assert_eq!(v["pixel_aligned"], true); + assert_eq!(v["warp_piece"], true); assert_eq!(serde_json::from_value::(v).unwrap(), n); let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 34d8e99c53..885f9a5853 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -56,6 +56,7 @@ impl Serialize for Node { e.envelope_edit_margin.is_none(), is_default(&e.pixel_aligned), e.color_mask.is_none(), + is_default(&e.warp_piece), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -100,6 +101,7 @@ impl Serialize for Node { field!("envelope_edit_margin", &e.envelope_edit_margin); field!("pixel_aligned", &e.pixel_aligned); field!("color_mask", &e.color_mask); + field!("warp_piece", &e.warp_piece); st.end() } } @@ -245,6 +247,8 @@ pub(super) struct NodeWire { pixel_aligned: bool, #[serde(default)] color_mask: Option>, + #[serde(default)] + warp_piece: bool, } impl From for Node { @@ -274,6 +278,7 @@ impl From for Node { envelope_edit_margin: w.envelope_edit_margin, pixel_aligned: w.pixel_aligned, color_mask: w.color_mask, + warp_piece: w.warp_piece, }; Node { id: w.id, diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index 26a11cf733..35de7cce73 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -121,19 +121,22 @@ impl ObjectsOnPath { if a.determinant() < 0.0 && self.rotate != 0.0 { self.rotate = -self.rotate; } - let along = paths.and_then(|(old, new)| { - let s = path_length(new)?.0 / path_length(old)?.0; - (s.is_finite() && s > 0.0).then_some(s) - }); - let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt()); // A move leaves the distances along the path as they are. let moves = a0 == 1.0 && a1 == 0.0 && a2 == 0.0 && a3 == 1.0; - if !moves && scale.is_finite() && scale > 0.0 { - let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v); - let moved = paths.and_then(|(old, new)| self.moved_along(a, old, new, scale)); - let (spacing, start) = moved.unwrap_or((None, None)); - self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g))); - self.start = start.unwrap_or_else(|| scaled(self.start)); + if !moves { + let measured = paths.and_then(|(old, new)| Some((Measured::new(old)?, Measured::new(new)?))); + let along = measured.as_ref().and_then(|(was, is)| { + let s = is.len / was.len; + (s.is_finite() && s > 0.0).then_some(s) + }); + let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt()); + if scale.is_finite() && scale > 0.0 { + let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v); + let moved = measured.as_ref().and_then(|(was, is)| self.moved_along(a, was, is, scale)); + let (spacing, start) = moved.unwrap_or((None, None)); + self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g))); + self.start = start.unwrap_or_else(|| scaled(self.start)); + } } let after = tilt(self.rotate); for m in &mut self.members { @@ -150,11 +153,10 @@ impl ObjectsOnPath { /// point on `old`, mapped by `a`, measured along `new` near `scale` times its distance along /// `old`. Either one is `None` when it cannot be followed that way: a start before or beyond the /// ends of an open path, or a Space value of 0, one the path has no room for, or one with fewer - /// than two objects. `None` for a path without length or a start that is not finite. - fn moved_along(&self, a: Affine, old: &PathData, new: &PathData, scale: f64) -> Option<(Option, Option)> { - let (was, is) = (Spine::new(old)?, Spine::new(new)?); - let (len, closed) = path_length(old)?; - let (new_len, _) = path_length(new)?; + /// than two objects. `None` for a start that is not finite. + fn moved_along(&self, a: Affine, old: &Measured, new: &Measured, scale: f64) -> Option<(Option, Option)> { + let (was, is) = (&old.spine, &new.spine); + let (len, closed, new_len) = (old.len, old.closed, new.len); if !self.start.is_finite() { return None; } @@ -230,10 +232,10 @@ pub fn is_objects_on_path(n: &Node) -> bool { } /// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path -/// (not a guide) whose first subpath has some length. +/// (not a guide) whose first subpath has a length ([`path_length`]). pub fn attachable_path(n: &Node) -> Option<&PathData> { match &n.kind { - NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path), + NodeKind::Path { path, guide: false, .. } if path_length(path).is_some() => Some(path), _ => None, } } @@ -292,11 +294,27 @@ pub fn upright_frame(n: &Node) -> Affine { } /// The length of `path`'s first subpath and whether it is closed. `None` for a path without -/// length. +/// length, or with a length too large to be finite. pub fn path_length(path: &PathData) -> Option<(f64, bool)> { - let len = Spine::new(path)?.length(); - let closed = path.subpaths.first().is_some_and(|s| s.closed); - (len.is_finite() && len > 1e-9).then_some((len, closed)) + Measured::new(path).map(|m| (m.len, m.closed)) +} + +/// A path's first subpath flattened ([`Spine`]), with its length and whether it is closed, so that +/// one flattening serves both measuring the path and finding points along it. +struct Measured { + spine: Spine, + len: f64, + closed: bool, +} + +impl Measured { + /// `None` for a path without length, or with a length too large to be finite. + fn new(path: &PathData) -> Option { + let spine = Spine::new(path)?; + let len = spine.length(); + let closed = path.subpaths.first().is_some_and(|s| s.closed); + (len.is_finite() && len > 1e-9).then_some(Self { spine, len, closed }) + } } /// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the @@ -341,18 +359,20 @@ fn wrap(s: f64, len: f64) -> f64 { /// (radians, as `atan2` gives in the document's axes). They go along it as [`layout`] spaces them /// for Space `spacing` (`None`: Auto) from `start`. `None` for a path without length. pub fn stops(path: &PathData, n: usize, spacing: Option, start: f64) -> Option> { - let spine = Spine::new(path)?; - let (len, closed) = path_length(path)?; + let Measured { spine, len, closed } = Measured::new(path)?; let (start, gap) = layout(len, closed, n, spacing, start); Some((0..n).map(|i| spine.at_length(nth_length(len, closed, start, gap, i))).collect()) } /// Where object `i` of `n` attaches along `path`, as [`stops`] places it, without the others. +/// `None` for an `i` of `n` or more, without measuring the path. pub fn stop(path: &PathData, n: usize, spacing: Option, start: f64, i: usize) -> Option<(Point, f64)> { - let spine = Spine::new(path)?; - let (len, closed) = path_length(path)?; + if i >= n { + return None; + } + let Measured { spine, len, closed } = Measured::new(path)?; let (start, gap) = layout(len, closed, n, spacing, start); - (i < n).then(|| spine.at_length(nth_length(len, closed, start, gap, i))) + Some(spine.at_length(nth_length(len, closed, start, gap, i))) } /// The distance along a path of length `len` (closed or not) to object `i`, from `start` and @@ -400,11 +420,15 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool { let old = rec.clone(); let NodeKind::Group { children, .. } = &mut g.kind else { return false }; let path_id = rec.path; - let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else { + let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })).cloned() else { g.extras_mut().objects_on_path = None; return true; }; - let path = attachable_path(path).cloned(); + // `stops` measures the path: on a path without length no object moves. + let path = match &path.kind { + NodeKind::Path { path, guide: false, .. } => Some(path), + _ => None, + }; let at: HashMap = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect(); rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id)); let mut listed: HashSet = rec.members.iter().map(|m| m.id).collect(); @@ -414,7 +438,7 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool { } } let mut moved = false; - if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len(), rec.spacing, rec.start)) { + if let Some(stops) = path.and_then(|p| stops(p, rec.members.len(), rec.spacing, rec.start)) { let turn = tilt(rec.rotate); for (m, (p, dir)) in rec.members.iter_mut().zip(stops) { let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue }; @@ -709,6 +733,72 @@ mod tests { assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}"); } + /// What `f` returns, and how many times it flattened a path ([`Spine::new`]). + fn flattenings(f: impl FnOnce() -> T) -> (T, usize) { + crate::blend::SPINES_BUILT.with(|n| n.set(0)); + let out = f(); + (out, crate::blend::SPINES_BUILT.with(|n| n.get())) + } + + #[test] + fn stops_and_stop_flatten_the_path_once() { + let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + for path in [&open, &closed] { + let (all, count) = flattenings(|| stops(path, 4, Some(40.0), 25.0)); + let all = all.unwrap(); + assert_eq!((all.len(), count), (4, 1)); + for (i, s) in all.iter().enumerate() { + assert_eq!(flattenings(|| stop(path, 4, Some(40.0), 25.0, i)), (Some(*s), 1), "object {i}"); + } + assert_eq!(flattenings(|| stop(path, 4, Some(40.0), 25.0, 4)), (None, 0), "an index past the last object"); + assert_eq!(flattenings(|| stop(path, 0, None, 0.0, 0)), (None, 0), "no objects"); + } + let point = shapes::line(Point::ZERO, Point::ZERO); + assert!(stops(&point, 2, None, 0.0).is_none() && stop(&point, 2, None, 0.0, 0).is_none()); + } + + #[test] + fn transforming_the_group_flattens_each_path_once() { + let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + let b = rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)); + let level = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let stretch = Affine::scale_non_uniform(2.0, 1.0); + let wide = level.transformed(stretch); + let mut rec = ObjectsOnPath { spacing: Some(40.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) }; + let ((), count) = flattenings(|| rec.transform(stretch, Some((&level, &wide)))); + assert_eq!(count, 2, "the path before the transform and after it"); + assert!((rec.start - 20.0).abs() < 1e-9 && (rec.spacing.unwrap() - 80.0).abs() < 1e-9, "{rec:?}"); + let moved = level.transformed(Affine::translate((5.0, 5.0))); + let ((), count) = flattenings(|| rec.transform(Affine::translate((5.0, 5.0)), Some((&level, &moved)))); + assert_eq!(count, 0, "a move leaves the distances along the path as they are"); + } + + #[test] + fn placing_the_objects_flattens_the_path_once() { + let line = Node::path(NodeId(1), shapes::line(Point::new(0.0, 100.0), Point::new(300.0, 100.0)), Default::default()); + let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0))); + let mut g = Node::new(NodeId(4), NodeKind::Group { children: vec![Arc::new(line), Arc::new(a.clone()), Arc::new(b.clone())], clip: false }); + g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0))); + let empty = crate::Document::new(1.0, 1.0); + let symbols = empty.symbol_boxes(); + assert_eq!(flattenings(|| place(&mut g, &symbols)), (true, 1)); + let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x; + assert!((x(NodeId(2))).abs() < 1e-9 && (x(NodeId(3)) - 300.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3))); + } + + /// A path whose length adds up past the largest finite number cannot be measured, so nothing + /// attaches to it. + #[test] + fn a_path_too_long_to_measure_is_not_attachable() { + let huge = Node::path(NodeId(1), shapes::line(Point::new(-1e308, 0.0), Point::new(1e308, 0.0)), Default::default()); + let NodeKind::Path { path, .. } = &huge.kind else { panic!("a path") }; + assert!(path_length(path).is_none() && stops(path, 2, None, 0.0).is_none()); + assert!(attachable_path(&huge).is_none()); + let square = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + assert!(attachable_path(&square).is_some()); + } + #[test] fn a_group_without_its_path_is_a_plain_group() { let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index 0812095826..c451fea7be 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -194,6 +194,27 @@ impl ImageBlob { .collect(); Some((w, h, cover)) } + + /// The pixels as straight RGBA, row by row from the top, with the width and the height; + /// `None` when the image can't be decoded within [`MAX_RASTER_ALLOC`]. + pub fn rgba(&self) -> Option<(u32, u32, Vec)> { + let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?; + let (w, h) = img.dimensions(); + Some((w, h, img.into_raw())) + } + + /// The width and height in pixels the image's file says it has; `None` for data that isn't an + /// image the decoder reads. Reads only its header. + pub fn pixel_size(&self) -> Option<(u32, u32)> { + image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions().ok() + } + + /// Whether the image's file is a PNG that stores palette indices (an indexed-color image). + /// Reads only its header. + pub fn indexed_png(&self) -> bool { + // The signature, then the IHDR chunk: length, type, width, height, bit depth, color type. + self.bytes.starts_with(b"\x89PNG\r\n\x1a\n") && self.bytes.get(12..16) == Some(b"IHDR".as_slice()) && self.bytes.get(25) == Some(&3) + } } impl ImageBlob { @@ -266,6 +287,20 @@ impl ImageBlob { Some(out) } + /// Whether any pixel of the image is less than fully opaque. `false` when the image can't be + /// decoded. The last image decoded is kept, as for [`Self::sample`]. + pub fn has_transparency(&self) -> bool { + self.decoded().is_some_and(|img| img.pixels().any(|p| p.0[3] < 255)) + } + + /// The alpha of each pixel, row by row from the top: the width, the height and one byte a + /// pixel. `None` when the image can't be decoded. The last image decoded is kept, as for + /// [`Self::sample`]. + pub fn alpha(&self) -> Option<(u32, u32, Vec)> { + let img = self.decoded()?; + Some((img.width(), img.height(), img.pixels().map(|p| p.0[3]).collect())) + } + /// The decoded pixels (the last image decoded is kept for the next sample). fn decoded(&self) -> Option> { thread_local! { diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index ba79f7c280..844aa8c810 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -388,7 +388,9 @@ pub fn graph_paints(n: &Node) -> impl Iterator { let g = n.extras().graph.as_deref(); let series = g.into_iter().flat_map(|g| &g.series_paints).flat_map(|s| [s.fill.as_ref(), s.stroke.as_ref()]).flatten(); let labels = g.into_iter().flat_map(|g| [&g.label_styles.values, &g.label_styles.categories, &g.label_styles.legend]).flatten(); - series.chain(labels.flat_map(|st| [&st.fill, &st.stroke])) + let l = g.map(|g| &g.label_styles); + let rows = l.into_iter().flat_map(|l| [&l.values_rows, &l.categories_rows, &l.legend_rows]).flatten(); + series.chain(labels.flat_map(|st| [&st.fill, &st.stroke])).chain(rows.flat_map(|r| [r.paint.fill.as_ref(), r.paint.stroke.as_ref()]).flatten()) } /// Mutable [`graph_paints`] of graph `g`, in the same order. @@ -396,7 +398,10 @@ fn graph_paints_mut(g: &mut crate::graph::GraphSpec) -> impl Iterator, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue; - assert_eq!(d.map_solid_paints(&mut to_blue), 4); + assert_eq!(d.map_solid_paints(&mut to_blue), 5); let g = d.node(a).unwrap().extras().graph.clone().unwrap(); assert_eq!(g.series_paints[0].fill, Some(linked(blue, "Brand"))); assert_eq!(g.series_paints[0].stroke, Some(linked(blue, "Brand"))); let legend = g.label_styles.legend.unwrap(); assert_eq!((legend.fill, legend.stroke), (linked(blue, "Brand"), Paint::None)); + assert_eq!(g.label_styles.values_rows.unwrap().paint.fill, Some(linked(blue, "Brand"))); let mut cleared = |p: &Paint| (*p == linked(blue, "Brand")).then_some(Paint::None); - assert_eq!(d.map_paints(&mut cleared), 4, "the fill and the graph's three"); + assert_eq!(d.map_paints(&mut cleared), 5, "the fill and the graph's four"); assert!(graph_paints(d.node(a).unwrap()).all(Paint::is_none)); } diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index d69da9bae2..440c379283 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -17,7 +17,10 @@ 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, ScriptMetrics}; +pub use char_style::{ + CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, + WARICHU_SCALE, WarichuAlign, +}; pub use composite::{ COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart, CompositeParts, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index a880a2a2bc..16b63e0809 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -145,6 +145,29 @@ pub struct CharStyle { /// #[serde(default, rename = "standardVerticalRoman", skip_serializing_if = "crate::skip::is_default")] pub standard_vertical_roman: bool, + /// Warichu (Character panel menu, East Asian options): these characters are set at + /// [`Self::warichu_scale`] percent of their size and stacked on [`Self::warichu_lines`] lines + /// inside their line, one above the other in horizontal type and side by side in vertical type. + /// A stretch of marked characters is one block that takes the settings of its first character + /// and stays whole on one line. Type on a path sets them at their own size on the path. + /// + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub warichu: bool, + /// Warichu Settings › Lines: how many lines a warichu block is stacked on, within + /// [`WARICHU_LINES`]. Saved only when not 2. + #[serde(default = "two", rename = "warichuLines", skip_serializing_if = "is_two")] + pub warichu_lines: u8, + /// Warichu Settings › Line Gap, in points from 0 to [`MAX_WARICHU_GAP`]: the space between the + /// lines of a warichu block. Saved only when not 0. + #[serde(default, rename = "warichuGap", skip_serializing_if = "crate::skip::is_default")] + pub warichu_gap: f64, + /// Warichu Settings › Scale: the size of warichu characters as a percentage of their size, + /// within [`WARICHU_SCALE`]. Saved only when not 50. + #[serde(default = "fifty", rename = "warichuScale", skip_serializing_if = "is_fifty")] + pub warichu_scale: f64, + /// Warichu Settings › Alignment: how the lines of a warichu block line up along the block. + #[serde(default, rename = "warichuAlign", skip_serializing_if = "crate::skip::is_default")] + pub warichu_align: WarichuAlign, /// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of /// these characters are drawn in, whatever digits were typed. #[serde(default, skip_serializing_if = "crate::skip::is_default")] @@ -417,6 +440,82 @@ 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 number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]). +pub const WARICHU_LINES: std::ops::RangeInclusive = 1..=6; + +/// The largest Warichu Settings Line Gap ([`CharStyle::warichu_gap`]), in points. +pub const MAX_WARICHU_GAP: f64 = 100.0; + +/// The Warichu Settings Scale ([`CharStyle::warichu_scale`]) range, in percent of the size. +pub const WARICHU_SCALE: std::ops::RangeInclusive = 10.0..=100.0; + +fn two() -> u8 { + 2 +} + +fn is_two(v: &u8) -> bool { + *v == 2 +} + +fn fifty() -> f64 { + 50.0 +} + +fn is_fifty(v: &f64) -> bool { + *v == 50.0 +} + +/// How the lines of a warichu block line up along the block ([`CharStyle::warichu_align`]): +/// each line set flush left (top, in vertical type), centered or flush right (bottom), or spread +/// to the block's length with the last line set flush left, centered or flush right, or spread as +/// well. Auto is the default and sets the lines as [`WarichuAlign::JustifyLeft`] does. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WarichuAlign { + #[default] + Auto, + Left, + Center, + Right, + JustifyLeft, + JustifyCenter, + JustifyRight, + JustifyAll, +} + +impl WarichuAlign { + /// Every alignment, in the order Warichu Settings lists them. + pub const ALL: [WarichuAlign; 8] = [ + WarichuAlign::Auto, + WarichuAlign::Left, + WarichuAlign::Center, + WarichuAlign::Right, + WarichuAlign::JustifyLeft, + WarichuAlign::JustifyCenter, + WarichuAlign::JustifyRight, + WarichuAlign::JustifyAll, + ]; + + /// The name files and commands give this alignment (as it is saved). + pub fn id(self) -> &'static str { + match self { + WarichuAlign::Auto => "auto", + WarichuAlign::Left => "left", + WarichuAlign::Center => "center", + WarichuAlign::Right => "right", + WarichuAlign::JustifyLeft => "justifyLeft", + WarichuAlign::JustifyCenter => "justifyCenter", + WarichuAlign::JustifyRight => "justifyRight", + WarichuAlign::JustifyAll => "justifyAll", + } + } + + /// The alignment [`WarichuAlign::id`] names; `None` for any other name. + pub fn from_id(id: &str) -> Option { + WarichuAlign::ALL.into_iter().find(|a| a.id() == id) + } +} + pub(super) fn yes() -> bool { true } @@ -464,6 +563,11 @@ impl Default for CharStyle { tcy_up_down: 0.0, tcy_left_right: 0.0, standard_vertical_roman: false, + warichu: false, + warichu_lines: 2, + warichu_gap: 0.0, + warichu_scale: 50.0, + warichu_align: WarichuAlign::Auto, small_caps: None, digits: Digits::Default, font_height_reference: FontHeightReference::EmBox, @@ -484,7 +588,10 @@ 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. + /// [`MAX_AKI`], and one that is not a number becomes 0. 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. pub fn keep_dimensions_in_range(&mut self) { let default = Self::default(); let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or }; @@ -499,6 +606,10 @@ 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.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 = + if self.warichu_scale.is_nan() { default.warichu_scale } else { self.warichu_scale.clamp(*WARICHU_SCALE.start(), *WARICHU_SCALE.end()) }; } /// Do the characters draw a stroke (a paint and a positive weight)? pub fn has_stroke(&self) -> bool { diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 68f41abd5b..dd4002dd60 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -34,10 +34,19 @@ impl TextObject { style.baseline_shift *= scale.points; style.tcy_up_down *= scale.points; style.tcy_left_right *= scale.points; + style.warichu_gap *= scale.points; style.h_scale *= scale.horizontal; - if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.tcy_up_down, style.tcy_left_right, style.h_scale] - .iter() - .all(|v| v.is_finite()) + if ![ + style.size, + style.leading.unwrap_or(0.0), + style.baseline_shift, + style.tcy_up_down, + style.tcy_left_right, + style.warichu_gap, + style.h_scale, + ] + .iter() + .all(|v| v.is_finite()) { return None; } @@ -114,6 +123,12 @@ impl TextObject { pub fn plain_text(&self) -> String { self.runs.iter().map(|r| r.text.as_str()).collect() } + /// Does layout stack some of this type's characters as warichu ([`CharStyle::warichu`])? Not + /// on a path, which sets them as they are. Exports that write live text write such type as + /// outlines, as they do ruby: their text can't place the stacked lines. + pub fn stacks_warichu(&self) -> bool { + !matches!(self.kind, TextKind::OnPath { .. }) && self.runs.iter().any(|r| r.style.warichu && !r.text.is_empty()) + } /// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them. /// /// Called after loading and editing text: ranges from a file or an edit are untrusted, and a @@ -492,8 +507,23 @@ 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.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap); + st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale); st.keep_dimensions_in_range(); + // Lines is a whole number: a stored number outside the range, or with a fraction, + // becomes the nearest whole number within it. + if let Some(old) = def.attrs.get_mut("warichuLines") + && old.is_number() + && old.as_u64().is_none_or(|v| !super::WARICHU_LINES.contains(&u8::try_from(v).unwrap_or(u8::MAX))) + { + let (lo, hi) = (*super::WARICHU_LINES.start(), *super::WARICHU_LINES.end()); + let v = old.as_f64().filter(|v| v.is_finite()).map_or(2.0, |v| v.round().clamp(f64::from(lo), f64::from(hi))); + // Within 1..6 after the clamp, so the cast keeps the value. + *old = (v as u8).into(); + } let kept = [ + ("warichuGap", Some(st.warichu_gap)), + ("warichuScale", Some(st.warichu_scale)), ("size", Some(st.size)), ("h_scale", Some(st.h_scale)), ("v_scale", Some(st.v_scale)), diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index 4fcaa8d078..b8531b402d 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, underline and strikethrough. + /// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough. pub fn marker_style(first: &CharStyle) -> CharStyle { CharStyle { baseline_shift: 0.0, @@ -370,6 +370,7 @@ impl ParaList { aki_right: 0.0, optical_kerning: first.optical_kerning, tate_chu_yoko: false, + warichu: false, underline: false, strikethrough: false, ..first.clone() diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index 444e814d86..a3e31f1842 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -1,7 +1,8 @@ //! The region a clip group clips to ([`Node::clip_outline`]) with text outlined by the font engine //! and several shapes united, so the renderer and the SVG and PDF writers clip alike. -use vectorcraft_doc::{Appearance, Node, NodeKind}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeKind}; use vectorcraft_geom::{BezPath, FillRule, PathData}; /// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their @@ -11,6 +12,209 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { clip.clip_outline(Some(&outline_text), &unite) } +/// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque +/// in `color`, which the SVG and PDF writers put in their knockout masks: for a clip group, a +/// rectangle clipped by the same clipping path (so a viewer covers the same pixels of its edges as +/// when it clips the piece), else the path of its opacity mask's art, filled. `None` for other +/// objects and for a piece with nothing to clip by. +pub fn knockout_silhouette(n: &Node, color: Color) -> Option { + let outline = n.knockout_outline()?; + let (bp, rule) = clip_outline(outline)?; + let paint = Appearance::basic(Paint::solid(color), Paint::None, 0.0); + if n.clips() && n.extras().mask.is_none() { + let cover = Node::path(n.id, vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Shape::bounding_box(&bp).inflate(1.0, 1.0)), paint); + let children = vec![outline.clone(), std::sync::Arc::new(cover)]; + return Some(Node::new(n.id, NodeKind::Group { children, clip: true })); + } + let mut p = Node::path(n.id, PathData::from_bezpath(&bp), paint); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = rule; + } + Some(p) +} + +/// Whether any of `pieces`, pieces of a mesh-warped image ([`Node::warp_piece_image`]), paints less +/// than opaque anywhere inside its triangle: it or its image is partly transparent, a color mask +/// clears some of its colors, a colorize paint draws its light pixels partly transparent, or the +/// pixels of its image (looked up in `doc`) are. Each distinct +/// image's pixels are scanned once. Pieces of an opaque image overlap without showing it, so the SVG +/// and PDF writers draw them one over another without masks. +pub fn warp_pieces_translucent(doc: &Document, pieces: &[&Node]) -> bool { + let mut scanned: Vec<*const vectorcraft_doc::ImageBlob> = vec![]; + for n in pieces { + let Some(image) = n.warp_piece_image() else { continue }; + let NodeKind::Image(im) = &image.kind else { continue }; + let e = image.extras(); + if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() { + return true; + } + let Some(blob) = doc.image_blob(im) else { continue }; + if scanned.contains(&std::ptr::from_ref(blob)) { + continue; + } + scanned.push(std::ptr::from_ref(blob)); + if blob.has_transparency() { + return true; + } + } + false +} + +/// Which overlaps of the pieces of a mesh-warped image ([`Node::warp_piece_image`]) can show +/// through. Where an upper piece overlaps a lower one only over fully opaque pixels of the image, +/// the SVG and PDF writers draw the upper piece over the lower one without a knockout mask for it: +/// a mask there would make the opaque pixels on the upper piece's antialiased edge lighter. +pub struct WarpSeams { + pieces: Vec>, + alphas: Vec<(u32, u32, Vec)>, +} + +/// A piece of a mesh-warped image whose pixels decide whether its overlaps show through. +struct SeamPiece { + /// The piece's triangle, in document space. + triangle: [vectorcraft_geom::Point; 3], + /// Maps document space to the pixels of its image as decoded. + to_pixels: vectorcraft_geom::Affine, + /// The index of its image's alpha in [`WarpSeams::alphas`]. + alpha: usize, +} + +/// How far from an overlap, in image pixels, a less than opaque pixel makes the overlap show +/// through: a smoothed image blends each pixel with its neighbors. +const SEAM_MARGIN: f64 = 2.0; + +impl WarpSeams { + /// The overlaps of `pieces` (pieces of a mesh-warped image, in paint order), with the pixels of + /// their images looked up in `doc`. Each distinct image is decoded once. + pub fn new(doc: &Document, pieces: &[&Node]) -> Self { + let mut images: Vec<*const vectorcraft_doc::ImageBlob> = vec![]; + let mut alphas = vec![]; + let pieces = pieces + .iter() + .map(|n| { + let image = n.warp_piece_image()?; + let NodeKind::Image(im) = &image.kind else { return None }; + let e = image.extras(); + if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() { + return None; + } + let triangle = triangle(n.knockout_outline()?)?; + let blob = doc.image_blob(im)?; + let alpha = match images.iter().position(|b| std::ptr::eq(*b, blob)) { + Some(i) => i, + None => { + alphas.push(blob.alpha()?); + images.push(std::ptr::from_ref(blob)); + alphas.len() - 1 + } + }; + let (w, h, _) = &alphas[alpha]; + if im.width == 0 || im.height == 0 || im.xf.determinant().abs() < 1e-12 { + return None; + } + let scale = vectorcraft_geom::Affine::scale_non_uniform(f64::from(*w) / f64::from(im.width), f64::from(*h) / f64::from(im.height)); + Some(SeamPiece { triangle, to_pixels: scale * im.xf.inverse(), alpha }) + }) + .collect(); + WarpSeams { pieces, alphas } + } + + /// Whether piece `above` (an index into the pieces) drawn over piece `below` can show the + /// lower piece through where they overlap: either piece or its image is partly transparent, a + /// color mask or a colorize paint changes its alpha, or a pixel of either image within + /// [`SEAM_MARGIN`] of the overlap is less than opaque. `true` for a piece that is not a + /// triangle of an image that can be decoded; `false` when the triangles do not overlap. + pub fn shows_through(&self, below: usize, above: usize) -> bool { + let (Some(Some(a)), Some(Some(b))) = (self.pieces.get(below), self.pieces.get(above)) else { return true }; + let overlap = convex_intersection(&a.triangle, &b.triangle); + overlap.len() >= 3 && [a, b].into_iter().any(|p| self.translucent_near(p, &overlap)) + } + + /// Whether a pixel of piece `p`'s image within [`SEAM_MARGIN`] of the convex polygon `overlap` + /// (document space) is less than opaque. + fn translucent_near(&self, p: &SeamPiece, overlap: &[vectorcraft_geom::Point]) -> bool { + let Some((w, h, alpha)) = self.alphas.get(p.alpha) else { return true }; + let poly: Vec = overlap.iter().map(|q| p.to_pixels * *q).collect(); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for q in &poly { + (x0, y0, x1, y1) = (x0.min(q.x), y0.min(q.y), x1.max(q.x), y1.max(q.y)); + } + let span = |lo: f64, hi: f64, n: u32| { + let lo = (lo - SEAM_MARGIN).floor().clamp(0.0, f64::from(n)) as u32; + let hi = (hi + SEAM_MARGIN).ceil().clamp(0.0, f64::from(n)) as u32; + lo..hi + }; + let orientation = signed_area(&poly).signum(); + for y in span(y0, y1, *h) { + for x in span(x0, x1, *w) { + let c = vectorcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5); + let near = poly.iter().zip(poly.iter().cycle().skip(1)).all(|(s, e)| { + let edge = *e - *s; + orientation * edge.cross(c - *s) >= -SEAM_MARGIN * edge.hypot() + }); + let i = y as usize * *w as usize + x as usize; + if near && alpha.get(i).is_none_or(|a| *a < 255) { + return true; + } + } + } + false + } +} + +/// The three corners of the straight-sided triangle `outline` draws, in document space. +fn triangle(outline: &Node) -> Option<[vectorcraft_geom::Point; 3]> { + let (bp, _) = clip_outline(outline)?; + let mut points = vec![]; + for el in bp.elements() { + match el { + vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) if points.is_empty() || points.last() != Some(p) => { + points.push(*p) + } + vectorcraft_geom::PathEl::MoveTo(_) | vectorcraft_geom::PathEl::LineTo(_) | vectorcraft_geom::PathEl::ClosePath => {} + _ => return None, + } + } + if points.len() == 4 && points.first() == points.last() { + points.pop(); + } + let [a, b, c] = points.as_slice() else { return None }; + Some([*a, *b, *c]) +} + +/// Twice the signed area of polygon `poly`. +fn signed_area(poly: &[vectorcraft_geom::Point]) -> f64 { + poly.iter().zip(poly.iter().cycle().skip(1)).map(|(a, b)| a.to_vec2().cross(b.to_vec2())).sum() +} + +/// The intersection of convex polygons `subject` and `clip`, as a convex polygon (empty when they +/// do not overlap). +fn convex_intersection(subject: &[vectorcraft_geom::Point], clip: &[vectorcraft_geom::Point]) -> Vec { + let orientation = signed_area(clip).signum(); + if orientation == 0.0 { + return vec![]; + } + let mut out = subject.to_vec(); + for (s, e) in clip.iter().zip(clip.iter().cycle().skip(1)) { + let edge = *e - *s; + let side = |p: vectorcraft_geom::Point| orientation * edge.cross(p - *s); + let input = std::mem::take(&mut out); + for (p, q) in input.iter().zip(input.iter().cycle().skip(1)) { + let (sp, sq) = (side(*p), side(*q)); + if sp >= 0.0 { + out.push(*p); + } + if (sp >= 0.0) != (sq >= 0.0) { + out.push(*p + (*q - *p) * (sp / (sp - sq))); + } + } + if out.is_empty() { + break; + } + } + out +} + /// Text as one path of its glyph outlines, in document space. fn outline_text(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ac61746266..6316a7a273 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -69,7 +69,7 @@ pub use adjust::{ is_channel, }; pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; -pub use clip::clip_outline; +pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent}; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art, diff --git a/crates/effects/src/lighting.rs b/crates/effects/src/lighting.rs index 9a352189d1..8592776a95 100644 --- a/crates/effects/src/lighting.rs +++ b/crates/effects/src/lighting.rs @@ -96,6 +96,18 @@ pub(crate) fn rgb_gradient_art( composed_art(id, path, paint, Ramp::Rgb(light), 0.75) } +/// The narrow stroke of a facet's own `paint`, `width` wide, that closes the hairline seam between +/// it and its neighbors where each is antialiased alone. Its joins are round: a facet seen almost +/// edge-on is a sharp sliver, and a mitered seam would reach far past its tip as a thorn, where a +/// round one reaches no more than half its width past the facet. Revolve's meets() counts on a +/// stroked seam reaching no more than half its width; faces drawn as clipped groups (whose clip, +/// in `composed_art`, already outlines the seam with round joins) are measured by their box. +pub(crate) fn seam_stroke(paint: Paint, width: f64) -> vectorcraft_doc::StrokeLayer { + let mut edge = vectorcraft_doc::StrokeLayer::new(paint, width); + edge.join = vectorcraft_doc::LineJoin::Round; + edge +} + /// Smooth Inflate triangles keep the seam within their narrow projected footprint. pub(crate) fn facet_art( id: vectorcraft_doc::NodeId, @@ -109,8 +121,7 @@ pub(crate) fn facet_art( let light = vectorcraft_three_d::LightGradient { diffuse: r, ambient: 0.0, color: [1.0; 3] }; if let Some(color) = paint.color() { let paint = smooth_paint(light, color); - let mut edge = vectorcraft_doc::StrokeLayer::new(paint.clone(), seam); - edge.join = vectorcraft_doc::LineJoin::Round; + let edge = seam_stroke(paint.clone(), seam); return vectorcraft_doc::Node::path( id, path.clone(), @@ -131,7 +142,7 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t use kurbo::Shape; use std::sync::Arc; use vectorcraft_doc::color::BlendMode; - use vectorcraft_doc::{Appearance, Node, NodeKind, StrokeLayer}; + use vectorcraft_doc::{Appearance, Node, NodeKind}; use vectorcraft_geom::{BezPath, PathData, Rect, shapes}; let mut boundary = BezPath::new(); @@ -163,8 +174,7 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t } else { Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) }; - let mut edge = StrokeLayer::new(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); - edge.join = vectorcraft_doc::LineJoin::Round; + let edge = seam_stroke(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); Arc::new(Node::path( id, face.path(), diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 9281a802bf..74b23340cd 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; @@ -494,10 +494,10 @@ fn surface( .into_iter() .flat_map(|item| { let AppearanceItem::Fill(fill) = &item else { return vec![item] }; - let mut edge = StrokeLayer::new(fill.paint.clone(), SEAM_WIDTH); - // Acute face corners must stay within the overlap width. Miter joins - // amplify this seam stroke into visible spikes along the silhouette. - edge.join = LineJoin::Round; + // Acute face corners must stay within the overlap width: meets() counts on a + // stroked seam reaching no more than half its width; faces drawn as clipped + // groups are measured by their box. + let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), SEAM_WIDTH); edge.opacity = fill.opacity; vec![item, AppearanceItem::Stroke(edge)] }) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 9b364c3d3a..a68ff9529f 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; +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}; @@ -209,6 +209,19 @@ pub(crate) fn solid_paints(n: &Node) -> Vec { runs } +/// [`solid_paints`] with, for each paint, the item of `n` it copies: the item a live-effect plug-in +/// before the paint's own 3D effect is told it sits on. The paints of a type's first run copy no +/// item of `n` and have none. +pub(crate) fn solid_paint_sources(n: &Node) -> (Vec>, Vec) { + let own: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect(); + if own.is_empty() { + let paints = solid_paints(n); + return (vec![None; paints.len()], paints); + } + let paints = own.iter().map(|i| (*i).clone()).collect(); + (own.into_iter().map(Some).collect(), paints) +} + fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result, String> { let view = view_options(p); match id { @@ -530,7 +543,7 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble group.blend = blend; return group; } - // Preserve existing white-light paints, alpha composition and seam joins exactly. + // Preserve existing white-light paints and alpha composition exactly. let (opaque, mask) = if colored { crate::lighting::opaque_paint(paint) } else { (paint.clone(), None) }; let paint = &opaque; let children = facets @@ -551,10 +564,7 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble .into_iter() .flat_map(|item| { let AppearanceItem::Fill(fill) = &item else { return vec![item] }; - let mut edge = StrokeLayer::new(fill.paint.clone(), seam); - if colored { - edge.join = vectorcraft_doc::LineJoin::Round; - } + let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), seam); edge.opacity = fill.opacity; vec![item, AppearanceItem::Stroke(edge)] }) @@ -1774,7 +1784,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { } let (path, rule) = profile(n)?; let ctx = GeomContext::of(n); - let paints = solid_paints(n); + let (sources, paints) = solid_paint_sources(n); let mut children = vec![]; let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { @@ -1792,9 +1802,9 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { crate::raster::follow_paints(&mut paints, &n.appearance.effects); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; - for item in &paints { + for (source, item) in sources.into_iter().zip(&paints) { if let Some((index, fx)) = solid(item.effects()) { - let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); + let p = geometry(&path, item.effects().get(..index)?, &ctx.item(source.unwrap_or(item))); let (shadow, surfaces) = art_for(doc, n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; let later: Vec = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); let with_later = |mut art: Arc| { @@ -2089,16 +2099,16 @@ fn meshes_within(doc: Option<&Document>, n: &Node, total: &mut usize, limit: usi } else { let (path, rule) = object_profile(n, label)?; let ctx = GeomContext::of(n); - let paints = solid_paints(n); + let (sources, paints) = solid_paint_sources(n); if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }], total, limit)? } else { let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; - for item in &paints { + for (source, item) in sources.into_iter().zip(&paints) { if let Some((index, fx)) = solid(item.effects()) { - let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item))); out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }], total, limit)?); } } diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 919de831ce..23d8d0ffad 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node}; use vectorcraft_geom::Rect; use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Extrude, Facet, Light}; @@ -207,8 +207,8 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 .into_iter() .map(|face| { if wire { - let mut edge = StrokeLayer::new(paint.clone(), WIRE); - edge.join = vectorcraft_doc::LineJoin::Round; + // A wire edge is drawn as a seam is: a round-joined stroke of the paint. + let edge = crate::lighting::seam_stroke(paint.clone(), WIRE); return Arc::new(Node::path(n.id, face.path(), Appearance { items: vec![AppearanceItem::Stroke(edge)], ..Default::default() })); } let (tone, spec) = shading.tone(face.normal, &lights); @@ -218,18 +218,23 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 // Unshaded (No Shading, no shading color, or a face in full light) with no // highlight: the paint itself, its color space and swatch kept. Some(_) if (tone >= 1.0 || shade.is_none()) && spec <= 0.0 => { - vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))] + vec![ + AppearanceItem::Fill(FillLayer::new(paint.clone())), + AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)), + ] } Some(c) => { let fill = Paint::solid(shaded(c, tone, spec, shade)); - vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(StrokeLayer::new(fill, seam))] + vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))] } // Gradients and patterns keep their paint, darkened by a layer of the shading color // and lightened by one of white. As with the other 3D effects' pattern shadow, the // layers cover the whole face, transparent pattern cells included. None => { - let mut items = - vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))]; + let mut items = vec![ + AppearanceItem::Fill(FillLayer::new(paint.clone())), + AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)), + ]; for (color, share) in [(shade, 1.0 - tone), (Some(Color::WHITE), spec)] { if let Some(color) = color.filter(|_| share > 0.0) { let mut layer = FillLayer::new(Paint::solid(color)); diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 5126b76660..c825159dda 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -1,5 +1,5 @@ use serde_json::json; -use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_doc::{Document, NodeId, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, Point, Shape}; use super::*; @@ -1293,6 +1293,9 @@ fn inflate_reads_twist_taper_and_cap() { assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true)); } +/// Inflate under the legacy white light keeps the polygon faces it had before the colored-light +/// batching (each face a path of its own, not a channel group) and its seam's 0.05–0.75 pt range; +/// its seams' joins are round, as every 3D seam's. #[test] fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() { let mut n = square_node(); @@ -1305,7 +1308,9 @@ fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() { assert!(matches!(face.kind, NodeKind::Path { .. })); let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") }; assert!((0.05..=0.75).contains(&stroke.width)); - assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter); + // Round, as every seam is: a facet seen almost edge-on is a sliver, and a mitered seam + // reached past the solid as a thorn. + assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Round); } // A tiny shape exercises the lower bound, which otherwise disappears at grazing angles. n.transform(Affine::scale(0.001), false); @@ -1697,3 +1702,248 @@ fn classic_rotate_parameters_are_checked() { assert!(err.starts_with(says), "{p}: {err}"); } } + +/// Under a white light and under colored ones, in Extrude & Bevel, Inflate, Rotate, Extrude & +/// Bevel (Classic, each of its shading branches) and Rotate (Classic, its No Shading and Diffuse +/// surfaces and a gradient), a face seen almost edge-on (a sharp sliver) has +/// its seam stroke rounded at the corners: every seam's joins are round, and none reaches past its +/// face more than half its width as it is outlined (a mitered seam would reach past the solid as a +/// thorn up to ten times that beyond the face's tip). Each case's view makes some face with a +/// corner a miter would spike from. +#[test] +fn a_faces_seam_stays_within_half_its_width_of_the_face() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)], + )))); + // Does `path` have a convex corner sharper than 45° that a miter with `limit` would spike from + // (one sharper than 2·asin(1/limit) is bevelled instead, so it can't thorn; a reflex corner's + // miter points inward)? An open subpath's ends are no corners, and a closing anchor repeating + // the first is one corner. + fn acute(path: &PathData, limit: f64) -> bool { + let floor = 2.0 * (1.0 / limit).asin(); + path.subpaths.iter().any(|sp| { + let mut pts: Vec = sp.anchors.iter().map(|a| a.p).collect(); + pts.dedup_by(|a, b| a.distance(*b) < 1e-9); + if sp.closed && pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { + pts.pop(); + } + let n = pts.len(); + let corners: Vec = if sp.closed { (0..n).collect() } else { (1..n.saturating_sub(1)).collect() }; + // Which way the subpath turns as a whole: a convex corner turns the same way. + let area: f64 = (0..n).map(|k| pts[k].to_vec2().cross(pts[(k + 1) % n].to_vec2())).sum(); + n >= 3 + && corners.into_iter().any(|k| { + let (a, b, c) = (pts[(k + n - 1) % n], pts[k], pts[(k + 1) % n]); + let (u, v) = (a - b, c - b); + let convex = !sp.closed || (b - a).cross(c - b) * area > 0.0; + let angle = (u.dot(v) / (u.hypot() * v.hypot())).clamp(-1.0, 1.0).acos(); + convex && angle > floor && angle < std::f64::consts::FRAC_PI_4 + }) + }) + } + // Views that make slivers whose corners a miter would spike from, in every case. + let views = [(-35.0, 25.0), (-85.0, 15.0), (60.0, 60.0)]; + let mut cases = vec![]; + for &(x, y) in &views { + let view = json!({"rotationX": x, "rotationY": y, "rotationZ": 0, "depth": 60}); + // Rotate (Classic) takes no depth. + let flat = json!({"rotationX": x, "rotationY": y, "rotationZ": 0}); + let with = |extra: Value| { + let mut p = view.clone(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), None)); + // A gradient under a white light: its paint and the shadow fill over it each have a seam. + cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), Some(gradient.clone()))); + cases.push((INFLATE, x, y, with(json!({})), None)); + // Under colored lights: one, two, and a gradient's paint. + let one = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}]}); + let two = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]}); + cases.push((EXTRUDE, x, y, with(one.clone()), None)); + cases.push((EXTRUDE, x, y, with(two.clone()), None)); + cases.push((EXTRUDE, x, y, with(one), Some(gradient.clone()))); + cases.push((INFLATE, x, y, with(json!({"lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]})), None)); + // Rotate turns the art flat: a thin triangle, some corner of which stays sharp enough at + // every view; under a white light and a colored one. + cases.push((ROTATE, x, y, with(json!({})), None)); + cases.push((ROTATE, x, y, with(json!({"lights": [{"color": "#ff8080"}]})), None)); + // Classic: shaded, unshaded (No Shading) and a gradient's paint kept under its shading + // layers each make their seam their own way. + let bevel = json!({"bevel": "classic", "bevelHeight": 1.5}); + cases.push((CLASSIC_EXTRUDE, x, y, with(bevel.clone()), None)); + cases.push((CLASSIC_EXTRUDE, x, y, with(json!({"bevel": "classic", "bevelHeight": 1.5, "surface": "none"})), None)); + cases.push((CLASSIC_EXTRUDE, x, y, with(bevel), Some(gradient.clone()))); + // Rotate (Classic) draws its plane through Classic's surfaces: No Shading and Diffuse. + for surface in ["none", "diffuse"] { + let mut p = flat.clone(); + p["surface"] = json!(surface); + cases.push((CLASSIC_ROTATE, x, y, p, None)); + } + let mut p = flat.clone(); + p["surface"] = json!("diffuse"); + cases.push((CLASSIC_ROTATE, x, y, p, Some(gradient.clone()))); + } + // Classic's own paint for its fill: a swatch in CMYK, which its unshaded faces keep as it is. + let brick = Paint::Solid { color: Color::Cmyk { c: 0.0, m: 0.75, y: 0.9, k: 0.2 }, swatch: Some("Brick".into()), tint: 1.0 }; + // Rotate (Classic)'s one plane is shaded at some views, not at every one (the light can leave + // it as its paint), so its Diffuse case is held to a darkened face at one view at least. + let mut rotate_shaded = false; + for (id, x, y, params, paint) in cases { + let mut n = if id == ROTATE || id == CLASSIC_ROTATE { + let mut b = BezPath::new(); + b.move_to((100.0, 90.0)); + b.line_to((160.0, 110.0)); + b.line_to((100.0, 130.0)); + b.close_path(); + stroked(&b) + } else { + stroked_rect(4.0) + }; + let unshaded = params["surface"] == "none"; + for (k, item) in n.appearance.items.iter_mut().enumerate() { + match &paint { + Some(paint) => *item.paint_mut() = paint.clone(), + None if (id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE) && k == 0 => *item.paint_mut() = brick.clone(), + None => {} + } + } + // Each solid on its own (the fill's, then the stroke's), so every check holds for each. + for keep in 0..n.appearance.items.len() { + let mut solo = n.clone(); + let item = solo.appearance.items.remove(keep); + solo.appearance.items = vec![item.clone()]; + let source = item.paint().clone(); + let what = format!("{id} {params} gradient {} view ({x}, {y}) solid {keep}", paint.is_some()); + solo.appearance.effects.push(fx(id, params.clone())); + let art = solid_art(&solo).unwrap(); + let mut seamed = vec![]; + art.walk(&mut |face| { + if let Some(path) = face.path_data() + && face.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) + { + seamed.push((path.clone(), face.appearance.items.clone())); + } + }); + // Each Classic case (Extrude & Bevel and Rotate) reaches its own branch: an unshaded face + // keeps the paint itself (its swatch and color space too), a shaded one is the paint + // darkened, and a gradient's face keeps the gradient. + if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE { + let fills: Vec<&Paint> = seamed + .iter() + .filter_map(|(_, items)| match items.first() { + Some(AppearanceItem::Fill(f)) => Some(&f.paint), + _ => None, + }) + .collect(); + assert!(!fills.is_empty(), "{what}: faces"); + if paint.is_some() || unshaded { + assert!(fills.iter().all(|f| **f == source), "{what}: every face keeps its paint as it is"); + } else { + let [r, g, b] = source.color().map(|c| c.to_rgb()).unwrap_or([0.0; 3]); + let darkened = fills.iter().any(|f| match f { + Paint::Solid { color, swatch: None, .. } => { + let [fr, fg, fb] = color.to_rgb(); + fr <= r + 1e-4 && fg <= g + 1e-4 && fb <= b + 1e-4 && fr + fg + fb < r + g + b - 1e-3 + } + _ => false, + }); + if id == CLASSIC_ROTATE { + rotate_shaded |= darkened; + } else { + assert!(darkened, "{what}: some face shaded darker than its paint"); + } + } + } + // Every seamed fill (the paint's, a shadow's over it) has its seams checked, and on some + // face a miter would spike from. + let (mut checked, mut sliver): (usize, Vec) = (0, vec![]); + for (path, items) in &seamed { + let outline = path.to_bezpath(); + let acute_here = items.iter().find_map(|i| match i { + AppearanceItem::Stroke(s) => Some(acute(path, s.miter_limit)), + _ => None, + }); + let mut which = 0; + for item in items { + // A seam paints something (a stroke of no width or no paint draws nothing). + let AppearanceItem::Stroke(seam) = item else { continue }; + if seam.width <= 0.0 || matches!(seam.paint, Paint::None) { + continue; + } + if sliver.len() <= which { + sliver.resize(which + 1, false); + } + sliver[which] |= acute_here.unwrap_or(false); + which += 1; + checked += 1; + assert_eq!(seam.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins"); + // The seam outlined as it is drawn: no point of it farther from the face than + // half its width. + let drawn = crate::stroke::line_outline(&outline, seam, seam.width, seam.width * 1e-3); + let (mut farthest, mut points): (f64, usize) = (0.0, 0); + kurbo::flatten(drawn.iter(), seam.width * 1e-4, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + points += 1; + farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4)); + } + }); + assert!(points > 0, "{what}: a seam outlined as nothing"); + assert!(farthest <= seam.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", seam.width); + } + } + assert!(checked > 0, "{what}: seams checked"); + for (k, s) in sliver.iter().enumerate() { + assert!(s, "{what}: no face with an acute corner carries seam {k}"); + } + } + } + assert!(rotate_shaded, "Rotate (Classic), Diffuse: no view shades its plane darker than its paint"); +} + +/// Revolve (Classic) draws its faces through Classic's surfaces: on the plastic, diffuse and +/// unshaded surfaces, and with a gradient, every face's seam has round joins and reaches no more +/// than half its width past its face as it is outlined (the reach Revolve's overlap test counts on). +#[test] +fn classic_revolves_seams_are_round() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)], + )))); + let mut b = BezPath::new(); + b.move_to((100.0, 40.0)); + b.line_to((130.0, 60.0)); + b.line_to((130.0, 140.0)); + b.line_to((100.0, 160.0)); + let cases = [("plastic", Paint::solid(BLUE)), ("diffuse", Paint::solid(BLUE)), ("none", Paint::solid(BLUE)), ("plastic", gradient)]; + for (surface, paint) in cases { + let what = format!("{surface} gradient {}", matches!(paint, Paint::Gradient(_))); + let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(paint, Paint::None, 0.0)); + n.appearance.effects.push(fx(crate::CLASSIC_REVOLVE, json!({"surface": surface}))); + let art = crate::revolve_art(&n).unwrap(); + let mut seams = 0; + art.walk(&mut |face| { + let Some(path) = face.path_data() else { return }; + for item in &face.appearance.items { + let AppearanceItem::Stroke(s) = item else { continue }; + if s.width <= 0.0 { + continue; + } + seams += 1; + assert_eq!(s.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins"); + let drawn = crate::stroke::line_outline(&path.to_bezpath(), s, s.width, s.width * 1e-3); + let mut farthest: f64 = 0.0; + kurbo::flatten(drawn.iter(), s.width * 1e-4, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4)); + } + }); + assert!(farthest <= s.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", s.width); + } + }); + assert!(seams > 0, "{what}: seams checked"); + } +} diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs index 4400555d2b..7e7d2e1595 100644 --- a/crates/effects/src/solids/tests_shadow.rs +++ b/crates/effects/src/solids/tests_shadow.rs @@ -1,6 +1,6 @@ use serde_json::json; use vectorcraft_doc::color::Color; -use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_doc::{Document, NodeId, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath}; use super::*; diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 00e741bfe6..e6e7d89ab5 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -10,7 +10,7 @@ use vectorcraft_three_d::Facet; use super::{ Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, members, object_profile, region, - reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, + reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds, }; use crate::{GeomContext, is_geometry}; @@ -105,16 +105,16 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, S let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); let (path, rule) = object_profile(n, label)?; let ctx = GeomContext::of(n); - let paints = solid_paints(n); + let (sources, paints) = solid_paint_sources(n); if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]); } let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; - for item in &paints { + for (source, item) in sources.into_iter().zip(&paints) { if let Some((index, fx)) = solid(item.effects()) { - let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item))); let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 48665539d9..627cc1be03 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -8,6 +8,7 @@ mod imageoutline; mod painted; mod rasterfollow; mod scale; +mod solidcontext; mod transformfx; fn fx(id: &str, params: Value) -> Effect { diff --git a/crates/effects/src/tests/solidcontext.rs b/crates/effects/src/tests/solidcontext.rs new file mode 100644 index 0000000000..2de212dba1 --- /dev/null +++ b/crates/effects/src/tests/solidcontext.rs @@ -0,0 +1,74 @@ +//! A live-effect plug-in before a fill's or stroke's own 3D effect is told which fill or stroke it +//! is on, in the drawn art, the exported meshes and the wireframe. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeId}; +use vectorcraft_geom::{PathData, Rect, shapes}; +use vectorcraft_plugins::wat as tpl; + +use crate::{EXTRUDE, solid_art, three_d_meshes_in, three_d_wireframe_in}; + +/// Widths the plug-in's squares are drawn at: `LARGE` when it is told it is on the item it looks +/// for, `SMALL` otherwise. +const LARGE: f64 = 600.0; +const SMALL: f64 = 6.0; + +/// A rectangle with a fill, a 1 pt stroke and a second fill on top. The item at `at` (an index in +/// its appearance items) has, in its own effects, live-effect plug-in `name` and then Extrude & +/// Bevel. The plug-in returns a `LARGE` square when it is given `item`, and a `SMALL` one otherwise. +fn plugin_then_extrude(name: &str, at: usize, item: &str) -> Node { + let square = |w: f64| shapes::rectangle(Rect::new(0.0, 0.0, w, w)); + let id = format!("test.effects.solidcontext.{name}"); + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","params":{{}}}}"#); + let (large, small) = (json!({"objects": [{"path": square(LARGE)}]}).to_string(), json!({"objects": [{"path": square(SMALL)}]}).to_string()); + let module = wat::parse_str(tpl::when_given_else(&manifest, tpl::Given::Params, item, &large, &small)).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); + let mut appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::BLACK), 1.0); + appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))))); + let effects = appearance.items[at].effects_mut(); + effects.push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true }); + effects.push(crate::new_effect(EXTRUDE, &json!({})).unwrap()); + Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), appearance) +} + +/// The width of the union of `paths`' bounds. +fn width(paths: impl IntoIterator) -> f64 { + paths.into_iter().filter_map(|p| p.bounds()).reduce(|a, b| a.union(b)).map_or(0.0, |b| b.width()) +} + +/// Whether each of the art, the meshes and the wireframe `n`'s 3D effect draws is wider than half +/// of `LARGE`, with the widths. +fn drawn_large(n: &Node) -> ([bool; 3], [f64; 3]) { + let art = solid_art(n).unwrap(); + let mut paths = vec![]; + art.walk(&mut |c| paths.extend(c.path_data().cloned())); + let meshes = three_d_meshes_in(None, n).unwrap(); + let xs = meshes.iter().flat_map(|m| m.mesh.positions.iter().map(|p| p[0])); + let mesh_width = xs.clone().fold(f64::NEG_INFINITY, f64::max) - xs.fold(f64::INFINITY, f64::min); + let widths = [width(paths), mesh_width, width(three_d_wireframe_in(None, n).unwrap())]; + (widths.map(|w| w > LARGE / 2.0), widths) +} + +/// A plug-in before the second fill's Extrude & Bevel is given `{"fill": 1}`, not `"object"`, so +/// the art, meshes and wireframe are all built from the square it returns for that fill. +#[test] +fn a_plugin_before_a_fills_extrude_is_told_its_fill() { + let (large, widths) = drawn_large(&plugin_then_extrude("fill", 2, r#""item":{"fill":1}"#)); + assert_eq!(large, [true; 3], "the art, meshes and wireframe are {widths:?} wide"); +} + +/// A plug-in before the stroke's Extrude & Bevel is given `{"stroke": 0}`. +#[test] +fn a_plugin_before_a_strokes_extrude_is_told_its_stroke() { + let (large, widths) = drawn_large(&plugin_then_extrude("stroke", 1, r#""item":{"stroke":0}"#)); + assert_eq!(large, [true; 3], "the art, meshes and wireframe are {widths:?} wide"); +} + +/// A plug-in on a fill is not told it is on the object: the square it returns otherwise is the one +/// extruded. +#[test] +fn a_plugin_on_a_fill_is_not_told_it_is_on_the_object() { + let (large, widths) = drawn_large(&plugin_then_extrude("object", 2, r#""item":"object""#)); + assert_eq!(large, [false; 3], "the art, meshes and wireframe are {widths:?} wide"); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 166436b0c8..94d8add68b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -46,7 +46,7 @@ pub mod importmatches; pub(crate) mod inline; mod interactivity; mod intertwine; -mod layer; +pub(crate) mod layer; mod layerpanel; pub mod library; pub mod linkfiles; diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index 735d721335..d7cae868ed 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -20,7 +20,9 @@ use vectorcraft_doc::{Node, NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_render::effects; +use super::super::appearance::series_children_in_paint_order; use super::super::edit::selected_roots; +use super::super::graph::capture_series_object_opacity; use super::super::menucmds::whole_or; use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns}; use super::super::typecmd::refresh_bounds_of; @@ -90,7 +92,7 @@ pub fn specs() -> Vec { "Randomize Opacity", ["Object", "Plug-ins", "Arrange Tools"], None, - "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it → {count}", + "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md) → {count}", has_selection, randomize_opacity ), @@ -99,7 +101,7 @@ pub fn specs() -> Vec { "Sort Objects", ["Object", "Plug-ins", "Arrange Tools"], None, - "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. A run that changes nothing makes no step → {count}", + "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. With apply \"opacity\", on paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md). A run that changes nothing makes no step → {count}", has_multi, sort ), @@ -396,17 +398,30 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result { } let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32); s.edit("Randomize Opacity", |d, _| { + let mut changed = vec![]; for id in &ids { let o = rng.range(lo, hi); - if let Some(n) = d.node_mut(*id) { + if let Some(n) = d.node_mut(*id) + && n.opacity != o + { n.opacity = o; + changed.push(*id); } } + keep_series_opacity(d, &changed); Ok(()) })?; Ok(json!({ "count": ids.len() })) } +/// Store on their graphs the opacity just given to the paths among `changed` (objects whose opacity changed) that are +/// directly in a graph series, with their blend mode, so that every path of the series has them when the graph is drawn +/// again; the frontmost of them in a series sets them. +fn keep_series_opacity(d: &mut vectorcraft_doc::Document, changed: &[NodeId]) { + let marks = series_children_in_paint_order(d, changed); + capture_series_object_opacity(d, &marks); +} + /// An object Sort Objects sorts: its layer or group, its place in it, its box as /// `object.setBounds` measures it and its opacity (%). struct Sorted { @@ -510,6 +525,8 @@ fn sort(s: &mut Session, p: &Value) -> Result { n.opacity = (v / 100.0) as f32; } } + let changed: Vec = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect(); + keep_series_opacity(d, &changed); Ok(()) })?; return Ok(count); diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index 4fd1f1ce96..be32cf0a83 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -10,7 +10,7 @@ use vectorcraft_geom::{PathData, Point, Rect}; use vectorcraft_pathops::addons as po; use super::super::super::edit::outermost; -use super::super::super::layer::empty_artboards; +use super::super::super::layer::{empty_artboards, remove_artboards}; use crate::Result; /// Sine of the turn below which an anchor on a straight run is collinear. @@ -369,11 +369,7 @@ fn restructure(d: &mut Document, scope: &mut Scope, kind: Kind) -> Result Result = const { std::cell::Cell::new(0) }; +} + +/// The scope's objects after a pass replaced `found`: each found object gives way to the ids of +/// what replaces it, in its place, and the others keep their order. An id that `roots` lists more +/// than once is replaced at its first places, once for each time `found` lists it. One walk of +/// `roots`. +pub(super) fn replace_roots(roots: &[NodeId], found: &[(NodeId, Vec)]) -> Vec { + let mut with: HashMap = HashMap::with_capacity(found.len()); + for (id, nodes) in found { + with.entry(*id).or_insert((nodes.as_slice(), 0)).1 += 1; + } + let mut out = Vec::with_capacity(roots.len()); + for r in roots { + #[cfg(test)] + ROOTS_SEEN.set(ROOTS_SEEN.get() + 1); + match with.get_mut(r) { + Some((nodes, left)) if *left > 0 => { + *left -= 1; + out.extend(nodes.iter().map(|n| n.id)); + } + _ => out.push(*r), + } + } + out +} + /// Run `f` on a copy of each path in scope (not guides), and keep the copies it changed: `f` /// returns how many issues it fixed. fn reshape(d: &mut Document, scope: &Scope, f: impl Fn(&Node, &mut PathData) -> usize) -> Result { @@ -479,13 +505,9 @@ fn near_points(d: &mut Document, scope: &Scope) -> Result { fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result { let Scope::Document = scope else { return Ok(Outcome::default()) }; let empty = empty_artboards(d); - // The last first, so the others keep their places. - for &i in empty.iter().rev() { - if i < d.artboards.len() { - let id = d.artboards.remove(i).id; - d.retire_artboard_id(id); - d.retain_guides(sel, |g| g.artboard != Some(id)); - } + if empty.is_empty() { + return Ok(Outcome::default()); } + remove_artboards(d, sel, &empty)?; Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty }) } diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 1184687366..4d4ed6461b 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -389,6 +389,150 @@ fn the_scope_is_the_selection_or_the_document() { } } +/// The ids of the first layer's objects, bottom to top. +fn layer_ids(s: &Session) -> Vec { + match &s.doc().unwrap().doc.layers[0].kind { + NodeKind::Layer { children, .. } => children.iter().map(|c| c.id).collect(), + other => panic!("not a layer: {other:?}"), + } +} + +/// A plain group of the one object `id`. +fn group(s: &mut Session, id: NodeId) -> NodeId { + wrap(s, id, |children| NodeKind::Group { children, clip: false }) +} + +#[test] +fn unwrapping_selected_groups_keeps_their_places_and_order() { + let mut s = session(); + let c1 = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let a = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let g1 = group(&mut s, a); + let c2 = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + let empty = s + .edit("t", |d, _| { + let (parent, i, _) = d.position(c2).unwrap(); + let g = d.alloc_id(); + Ok(d.insert(parent, i + 1, Node::new(g, NodeKind::Group { children: vec![], clip: false }))?) + }) + .unwrap(); + let c3 = rect(&mut s, 100.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 130.0, 10.0, 20.0, 20.0); + let inner = group(&mut s, b); + let outer = group(&mut s, inner); + let c4 = rect(&mut s, 160.0, 10.0, 20.0, 20.0); + let x = rect(&mut s, 190.0, 10.0, 20.0, 20.0); + let unselected = group(&mut s, x); + assert_eq!(layer_ids(&s), [c1, g1, c2, empty, c3, outer, c4, unselected]); + select(&mut s, &[c1, g1, c2, empty, c3, outer, c4]); + let check = s.execute("addon.cleanup.check", &json!({"kinds": ["needlessGroups"], "scope": "selection"})).unwrap(); + let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["needlessGroups"], "scope": "selection"})).unwrap(); + // The inner group is found only once its outer group is gone: on a later pass, as a root. + assert_eq!(r["fixed"]["needlessGroups"], 4, "{r}"); + assert_eq!(check["total"], 4, "the check finds what Fix fixes: {check}"); + let mut listed: Vec = check["findings"][0]["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + listed.sort_unstable(); + let mut gone: Vec = [g1, empty, outer, inner].iter().map(|i| i.0).collect(); + gone.sort_unstable(); + assert_eq!(listed, gone); + assert_eq!(layer_ids(&s), [c1, a, c2, c3, b, c4, unselected], "each member takes its group's place"); + for id in [g1, empty, outer, inner] { + assert!(node(&s, id).is_none(), "{id:?} unwrapped"); + } +} + +#[test] +fn fix_all_reaches_the_members_of_unwrapped_selected_groups() { + let mut s = session(); + rect(&mut s, 200.0, 20.0, 80.0, 60.0); + let below = rect(&mut s, 20.0, 150.0, 30.0, 30.0); + let copy = s + .edit("t", |d, _| { + let mut copy = d.node(below).unwrap().clone(); + copy.id = d.alloc_id(); + let (parent, i, _) = d.position(below).unwrap(); + Ok(d.insert(parent, i + 1, copy)?) + }) + .unwrap(); + let g = group(&mut s, copy); + select(&mut s, &[below, g]); + // The copy sits apart from the object it doubles until its group is unwrapped. + assert_eq!(found(&mut s, "duplicateObjects", "selection"), 0); + assert_eq!(found(&mut s, "needlessGroups", "selection"), 1); + let r = s.execute("addon.cleanup.fix", &json!({"scope": "selection"})).unwrap(); + assert_eq!((r["fixed"]["needlessGroups"].as_u64(), r["fixed"]["duplicateObjects"].as_u64()), (Some(1), Some(1)), "{r}"); + assert!(node(&s, g).is_none() && node(&s, copy).is_none()); + assert!(node(&s, below).is_some()); +} + +#[test] +fn replacing_roots_takes_each_found_object_at_its_first_place() { + use super::checks::replace_roots; + let n = |id: u64| Node::new(NodeId(id), NodeKind::Group { children: vec![], clip: false }); + let ids = |v: &[u64]| v.iter().map(|i| NodeId(*i)).collect::>(); + let roots = ids(&[1, 2, 1, 3]); + assert_eq!(replace_roots(&roots, &[(NodeId(1), vec![n(10), n(11)])]), ids(&[10, 11, 2, 1, 3])); + assert_eq!(replace_roots(&roots, &[(NodeId(1), vec![]), (NodeId(1), vec![])]), ids(&[2, 3]), "listed twice: both places"); + assert_eq!(replace_roots(&roots, &[(NodeId(3), vec![]), (NodeId(2), vec![n(20)])]), ids(&[1, 20, 1])); + // A found object inside a root leaves the roots as they are. + assert_eq!(replace_roots(&roots, &[(NodeId(7), vec![n(70)])]), roots); + assert_eq!(replace_roots(&[], &[(NodeId(7), vec![])]), ids(&[])); +} + +/// How many scope roots fixing `n` selected stray points above `n` selected clean objects looks at. +fn roots_seen_removing(n: usize) -> usize { + use super::checks::ROOTS_SEEN; + let mut s = session(); + let mut ids: Vec = (0..n).map(|i| rect(&mut s, (i % 20) as f64 * 15.0, (i / 20) as f64 * 15.0, 10.0, 10.0)).collect(); + ids.extend((0..n).map(|i| path(&mut s, json!([{"x": i % 300, "y": i / 300}]), false))); + select(&mut s, &ids); + ROOTS_SEEN.set(0); + let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["strayPoints"], "scope": "selection"})).unwrap(); + assert_eq!(r["total"].as_u64(), Some(n as u64)); + assert_eq!(layer_ids(&s), ids[..n]); + ROOTS_SEEN.get() +} + +#[test] +fn removing_many_selected_objects_looks_at_each_root_once_a_pass() { + let (small, large) = (roots_seen_removing(150), roots_seen_removing(300)); + assert_eq!(small, 300, "one pass over the 300 roots"); + assert!(large <= 2 * small, "{small} roots looked at for 150 removals, {large} for 300"); +} + +#[test] +fn deleting_many_empty_artboards_walks_the_artboards_and_guides_once() { + use crate::cmd::layer::ARTBOARD_PASSES; + let mut s = session(); + rect(&mut s, 200.0, 20.0, 80.0, 60.0); + let (keep, gone) = s + .edit("t", |d, _| { + let first = d.artboards[0].id; + let keep = d.add_ruler_guide(&vectorcraft_doc::Guide { artboard: Some(first), ..vectorcraft_doc::Guide::new(true, 100.0) }); + let mut gone = vec![]; + for i in 0..40 { + let mut a = d.artboards[0].clone(); + a.id = d.next_artboard_id(); + let x = 1000.0 + i as f64 * 200.0; + a.rect = Rect::new(x, 1000.0, x + 100.0, 1100.0); + gone.push(d.add_ruler_guide(&vectorcraft_doc::Guide { artboard: Some(a.id), ..vectorcraft_doc::Guide::new(true, x + 50.0) })); + d.artboards.push(a); + } + Ok((keep, gone)) + }) + .unwrap(); + let first = s.doc().unwrap().doc.artboards[0].clone(); + ARTBOARD_PASSES.set(0); + let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["emptyArtboards"], "scope": "document"})).unwrap(); + assert_eq!(r["total"].as_u64(), Some(40), "{r}"); + assert_eq!(ARTBOARD_PASSES.get(), 2, "one walk over the artboards and one over the guides"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.artboards, vec![first]); + let left: Vec> = d.ruler_guides().into_iter().map(|g| g.id).collect(); + assert_eq!(left, vec![Some(keep)]); + assert!(gone.iter().all(|id| !left.contains(&Some(*id)))); +} + #[test] fn show_selects_the_objects_with_the_issue() { let mut s = session(); diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 7d7dcbb197..052ba901ed 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -206,11 +206,31 @@ struct Source { text: String, } +#[cfg(test)] +thread_local! { + /// Tests: how many times [`thread_index`] went through the document's threads on this thread. + static THREAD_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Each object the document's text threads list, with the index of the first thread that lists it. +fn thread_index(d: &Document) -> std::collections::HashMap { + #[cfg(test)] + THREAD_SCANS.with(|c| c.set(c.get() + 1)); + let mut out = std::collections::HashMap::new(); + for (i, thread) in d.text_threads.iter().enumerate() { + for id in thread { + out.entry(*id).or_insert(i); + } + } + out +} + impl Source { - /// Horizontal point or area type that isn't threaded and whose text all fits; None otherwise. - fn of(d: &Document, id: NodeId) -> Option { + /// Horizontal point or area type that isn't threaded (`threaded` false) and whose text all + /// fits; None otherwise. + fn of(d: &Document, id: NodeId, threaded: bool) -> Option { let t = text_of(d, id)?; - if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || d.text_threads.iter().any(|th| th.contains(&id)) { + if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || threaded { return None; } let lay = layout(t); @@ -376,8 +396,10 @@ fn break_text(s: &mut Session, p: &Value) -> Result { }; let d = &s.doc()?.doc; let (mut items, mut skipped, mut count) = (vec![], vec![], 0); - for id in texts_under(d, &checked_ids_or_selection(s, p, C)?)? { - let (Some(src), Some(node)) = (Source::of(d, id), d.node(id)) else { + let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?; + let threads = thread_index(d); + for id in ids { + let (Some(src), Some(node)) = (Source::of(d, id, threads.contains_key(&id)), d.node(id)) else { skipped.push(id); continue; }; @@ -405,7 +427,8 @@ fn split_text(s: &mut Session, _: &Value) -> Result { return Err(EngineError::Other("Click in type with a Type tool to split it".into())); }; let d = &s.doc()?.doc; - let (Some(src), Some(node)) = (Source::of(d, id), d.node(id).filter(|_| d.is_editable(id))) else { + let threaded = d.text_threads.iter().any(|th| th.contains(&id)); + let (Some(src), Some(node)) = (Source::of(d, id, threaded), d.node(id).filter(|_| d.is_editable(id))) else { return Err(EngineError::Other("Select horizontal point or area type whose text fits".into())); }; let text = &src.text; @@ -821,13 +844,17 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?; // A threaded story whose frames are all among them is read as one text, once. let named: std::collections::HashSet = ids.iter().copied().collect(); + let threads = thread_index(d); let mut done = std::collections::HashSet::new(); let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![]; for &id in &ids { if !done.insert(id) { continue; } - let story = crate::cmd::threads::story_of(d, id); + let story = match threads.get(&id).and_then(|&i| d.text_threads.get(i)) { + Some(thread) => crate::cmd::threads::story_in(d, thread, id), + None => vec![], + }; if !story.is_empty() && story.iter().all(|f| named.contains(f)) { done.extend(story.iter().copied()); out.extend(story_cuts(d, &story, brk)); diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index 3769cf3b42..cfa78a5ce5 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -158,6 +158,62 @@ fn break_text_refuses_what_it_cannot_break() { assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); } +/// Two area frames threaded together, with `story` flowing through them → (first, second). +fn threaded(s: &mut Session, story: &str) -> (NodeId, NodeId) { + let a = text(s, json!({"x": 10, "y": 300, "text": story, "size": 12, "area": {"width": 150, "height": 60}})); + let b = NodeId(s.execute("shape.rectangle", &json!({"x": 200, "y": 300, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap()); + select(s, &[a, b]); + s.execute("text.thread.create", &json!({})).unwrap(); + (a, b) +} + +fn thread_scans() -> usize { + super::THREAD_SCANS.with(|c| c.get()) +} + +/// Threaded frames in the selection stay as they are and are reported as skipped; the other type +/// is broken. The document's threads are gone through once for the whole selection, not once +/// per object. +#[test] +fn break_text_skips_threaded_frames_and_reads_the_threads_once() { + let mut s = session(); + let (a, b) = threaded(&mut s, "Threaded story text"); + let mut plain = vec![]; + for i in 0..6 { + plain.push(text(&mut s, json!({"x": 20, "y": 30 + 30 * i, "text": "Two words", "size": 14}))); + } + let was = glyphs(&s, &plain); + let frames = (obj(&s, a), obj(&s, b)); + let before = doc_json(&s); + let steps = undo_steps(&s); + let mut all = vec![a, b]; + all.extend(&plain); + select(&mut s, &all); + let scans = thread_scans(); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "words"})).unwrap(); + assert_eq!(thread_scans() - scans, 1, "the threads are gone through once"); + assert_eq!(ids(&json!({"ids": r["skipped"]})), [a, b]); + assert_eq!((obj(&s, a), obj(&s, b)), frames, "the threaded frames stay as they are"); + let pieces = ids(&r); + assert_eq!(pieces.len(), 12); + assert_eq!(s.doc().unwrap().selection.objects.len(), 12, "the pieces are selected"); + assert_same_places(&was, &glyphs(&s, &pieces)); + assert_eq!(undo_steps(&s), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before); +} + +#[test] +fn split_text_refuses_a_threaded_frame() { + let mut s = session(); + let (a, _) = threaded(&mut s, "Threaded story text"); + select(&mut s, &[a]); + edit_text(&mut s, a, 9, 9); + let before = doc_json(&s); + assert!(other(s.execute("addon.cleanup.splitText", &json!({}))).contains("point or area type")); + assert_eq!(doc_json(&s), before); +} + #[test] fn combine_puts_glyph_pieces_back_together_where_they_were() { let mut s = session(); @@ -300,6 +356,29 @@ fn remove_breaks_with_ids_null_in_the_characters_a_type_tool_selected() { assert_eq!(obj(&s, id).plain_text(), "a b\nc"); } +/// Remove Breaks reads a threaded story whose frames are all selected as one text and each other +/// object on its own, going through the document's threads once for the whole selection. +#[test] +fn remove_breaks_reads_the_threads_once() { + let mut s = session(); + let (a, b) = threaded(&mut s, "one\ntwo"); + let mut plain = vec![]; + for i in 0..6 { + plain.push(text(&mut s, json!({"x": 20, "y": 30 + 30 * i, "text": "a\nb", "size": 12}))); + } + let mut all = vec![a, b]; + all.extend(&plain); + select(&mut s, &all); + let scans = thread_scans(); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap(); + assert_eq!(thread_scans() - scans, 1, "the threads are gone through once"); + assert_eq!(r["removed"], 7); + assert_eq!(obj(&s, a).plain_text(), "one two"); + for id in plain { + assert_eq!(obj(&s, id).plain_text(), "a b"); + } +} + #[test] fn split_at_the_cursor_and_around_a_selection() { for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index cd30d29d15..fa8182c4c2 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Apply as Filter", ["Object", "Plug-ins", "Color Effects"], None, - "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does → {changed}", + "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}", has_selection, filter ), @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Print Ink Fixes", ["Object", "Plug-ins", "Color Effects"], None, - "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", + "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", has_doc, print_fixes::run ), @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Apply Nearest Swatch", ["Object", "Plug-ins", "Color Effects"], None, - "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", + "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", has_selection, nearest::apply ), @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "Apply Fill to Stroke", ["Object", "Plug-ins", "Color Effects"], None, - "{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's series marks and labels keep the paint when the graph is drawn again. skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}", + "{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's labels keep the paint when the graph is drawn again. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row (exceptions: docs/mcp.md). skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}", has_doc, |s, p| copy_paint(s, p, true) ), @@ -226,6 +226,8 @@ fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result { if !ids.is_empty() { let before = s.doc()?.doc.clone(); super::super::overprint::edit_counted(s, label, |d| { + // Each object with the row the paint went to, for the paint a graph stores for its series. + let mut written = Vec::new(); for id in &ids { let Some(node) = d.node_mut(*id) else { continue }; if let NodeKind::Text(t) = &mut node.kind { @@ -239,19 +241,23 @@ fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result { let (from, to) = if to_stroke { (ap.fill_paint(), ap.stroke_paint()) } else { (ap.stroke_paint(), ap.fill_paint()) }; // A missing item reads as None: copying None onto it would add an item that // changes nothing. - if (skip_none && from.is_none()) || from == to { + if skip_none && from.is_none() { continue; } - if to_stroke { - restore_unpainted_stroke(ap, None, &from); - ap.set_stroke(from); - } else { - ap.set_fill(from); + if from != to { + if to_stroke { + restore_unpainted_stroke(ap, None, &from); + ap.set_stroke(from); + } else { + ap.set_fill(from); + } } + // A row that already had the paint counts as written with it, as for `paint.setFill`. + written.extend(ap.items.iter().rposition(|it| it.is_fill() != to_stroke).map(|at| (*id, at))); } objects = super::super::appearance::count_changed(d, &before, &ids); // The graph keeps the paint its series marks and labels now have. - let series = super::super::graph::capture_series_paints(d, &ids, !to_stroke); + let series = super::super::appearance::capture_series_paint_writes(&before, d, &ids, &written); let labels = super::super::graph::capture_label_styles(d, &ids, ©_run); Ok(objects + usize::from(series || labels)) })?; diff --git a/crates/engine/src/cmd/addons/color/print_fixes.rs b/crates/engine/src/cmd/addons/color/print_fixes.rs index a0dd332fec..c5f430eb76 100644 --- a/crates/engine/src/cmd/addons/color/print_fixes.rs +++ b/crates/engine/src/cmd/addons/color/print_fixes.rs @@ -11,7 +11,7 @@ use vectorcraft_color::swatch::REGISTRATION; use vectorcraft_doc::overprint::{OverprintBlack, is_white}; use vectorcraft_doc::{ColorMode, Node, NodeKind}; -use super::super::super::colorcmds::{Recolor, Scope}; +use super::super::super::colorcmds::{Recolor, Scope, keeping_series_paint}; use super::super::super::overprint::edit_counted; use super::super::super::{bad, checked_ids_or_selection, number_or, switch_or, text_param}; use crate::{Result, Session}; @@ -149,7 +149,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { true }; let scope = Scope { fill: fixes.fill, stroke: fixes.stroke, images: false, patterns: false }; - ink = Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids); + ink = keeping_series_paint(d, &ids, |d| Ok(Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids)))?; } if !fixes.remove_white_overprint && fixes.overprint_black <= 0.0 { return Ok(ink); diff --git a/crates/engine/src/cmd/addons/cutting.rs b/crates/engine/src/cmd/addons/cutting.rs index 2734ca10b3..37e9f2fa79 100644 --- a/crates/engine/src/cmd/addons/cutting.rs +++ b/crates/engine/src/cmd/addons/cutting.rs @@ -1,9 +1,10 @@ //! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil //! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together //! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art -//! for vinyl cutting ([`weed`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are -//! the UI's. +//! for vinyl cutting ([`weed`]). Banner Hems and Grommets marks the hems and grommets of a banner +//! ([`banner`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are the UI's. +mod banner; mod stencil; mod weed; @@ -12,5 +13,6 @@ use super::super::CommandSpec; pub fn specs() -> Vec { let mut v = stencil::specs(); v.extend(weed::specs()); + v.extend(banner::specs()); v } diff --git a/crates/engine/src/cmd/addons/cutting/banner.rs b/crates/engine/src/cmd/addons/cutting/banner.rs new file mode 100644 index 0000000000..f4ae71ed54 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/banner.rs @@ -0,0 +1,228 @@ +//! Banner Hems and Grommets (`addon.cutting.banner`), a command of the built-in Cutting Tools +//! add-on for finishing a printed banner: on each chosen side of the banner it adds a hem line, +//! where the edge is folded back and sewn (), and grommet +//! marks, where rings are set into the material to hang it +//! (), spread evenly at most a set spacing apart. The +//! banner is the selected art's bounds, or each artboard. + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_doc::{Appearance, Node}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::super::super::edit::selected_roots; +use super::super::super::paint::swatch_solid; +use super::super::super::pathops::length_given; +use super::super::super::{CommandSpec, bad, cmd, has_doc, switch_or, text_param}; +use super::weed::outside_clips; +use crate::{Result, Session}; + +/// Banner Hems and Grommets' command. +pub const BANNER: &str = "addon.cutting.banner"; +/// The sides of a banner, in the order the grommets go round it. +const SIDES: [&str; 4] = ["top", "right", "bottom", "left"]; +/// The largest hem, inset or spacing, in points. +const MAX_DISTANCE: f64 = 100_000.0; +/// The most grommets one run adds. +const MAX_GROMMETS: usize = 100_000; +/// Two grommet centers closer than this are one grommet (the corner that two sides share). +const SAME: f64 = 1e-6; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.cutting.banner", + "Banner Hems and Grommets…", + ["Object", "Plug-ins", "Cutting Tools"], + None, + "{hem?: pt 0..100000 (72: from each edge to its hem line; 0: no hem lines), grommets?: bool (true), spacing?: pt more than 0 and at most 100000 (1728: the most the grommets on a side are apart), grommetInset?: pt 0..100000 (36: from each edge to the grommets' centers), diameter?: pt 0.01..1000 (27: each grommet mark's circle), top?, right?, bottom?, left?: bool (true: the sides that get a hem and grommets), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the marks are stroked in (\"[Registration]\"), allArtboards?: bool (default: true when nothing is selected)} mark the hems and grommets of a banner, the selected art's bounds or each artboard: a hem line `hem` inside each chosen edge, running the edge's whole length, and grommet marks (a circle with a cross at its center) `grommetInset` inside each chosen edge, one at each end of the side and the rest spread evenly between them, as few as keep them at most `spacing` apart. One group named Banner Marks per banner, stroked with no fill, on top of the current layer, selected, as one undo step → {id: the first group, ids, hems (hem lines), grommets (grommet marks)}", + has_doc, + banner + )] +} + +/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`. +fn distance(p: &Value, key: &str, default: f64) -> Result { + match length_given(p, key, BANNER)? { + None => Ok(default), + Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v), + Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))), + } +} + +/// The length `p` gives for `key` (0.01..1000), or `default`. +fn size(p: &Value, key: &str, default: f64) -> Result { + match length_given(p, key, BANNER)? { + None => Ok(default), + Some(v) if (0.01..=1000.0).contains(&v) => Ok(v), + Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0.01 to 1000 pt"))), + } +} + +/// Which sides are on, in [`SIDES`] order. +type Sides = [bool; 4]; + +/// Whether marks `depth` inside a banner's near and far edges, `len` apart, fit: they stay inside +/// the banner, and when both edges are chosen the two sets stay apart. +fn fits(depth: f64, len: f64, near: bool, far: bool) -> bool { + match (near, far) { + (true, true) => 2.0 * depth < len, + (true, false) | (false, true) => depth < len, + (false, false) => true, + } +} + +/// The hem lines of banner `r`: one `hem` inside each chosen edge, from one end of the edge to the +/// other. +fn hem_lines(r: Rect, hem: f64, sides: Sides) -> Vec<(Point, Point)> { + if hem <= 0.0 { + return vec![]; + } + let lines = [ + (Point::new(r.x0, r.y0 + hem), Point::new(r.x1, r.y0 + hem)), + (Point::new(r.x1 - hem, r.y0), Point::new(r.x1 - hem, r.y1)), + (Point::new(r.x0, r.y1 - hem), Point::new(r.x1, r.y1 - hem)), + (Point::new(r.x0 + hem, r.y0), Point::new(r.x0 + hem, r.y1)), + ]; + lines.into_iter().zip(sides).filter(|(_, on)| *on).map(|(l, _)| l).collect() +} + +/// The grommet centers of banner `r`: on each chosen side, `inset` inside the edge, one at each end +/// (`inset` from the edges across it) and the rest spread evenly between them, as few as keep them +/// at most `spacing` apart. They go round the banner clockwise from the top left; a corner that two +/// chosen sides share has one grommet. `None` when there would be more than `limit`. +fn grommet_centers(r: Rect, inset: f64, spacing: f64, sides: Sides, limit: usize) -> Option> { + let (l, t, rt, b) = (r.x0 + inset, r.y0 + inset, r.x1 - inset, r.y1 - inset); + // Each side from its start to its end, clockwise. + let runs = [ + (Point::new(l, t), Point::new(rt, t)), + (Point::new(rt, t), Point::new(rt, b)), + (Point::new(rt, b), Point::new(l, b)), + (Point::new(l, b), Point::new(l, t)), + ]; + let mut out: Vec = vec![]; + for ((a, z), on) in runs.into_iter().zip(sides) { + if !on { + continue; + } + let gaps = ((z - a).hypot() / spacing - 1e-9).ceil().max(1.0); + if !gaps.is_finite() || gaps > limit as f64 { + return None; + } + let gaps = gaps as usize; + for i in 0..=gaps { + let p = a.lerp(z, i as f64 / gaps as f64); + // A side starts where the one before it ends, and the last one ends where the first + // starts. + let shared = |q: Option<&Point>| q.is_some_and(|q| (*q - p).hypot() < SAME); + if !shared(out.last()) && !shared(out.first()) { + out.push(p); + } + if out.len() > limit { + return None; + } + } + } + Some(out) +} + +fn banner(s: &mut Session, p: &Value) -> Result { + let hem = distance(p, "hem", 72.0)?; + let inset = distance(p, "grommetInset", 36.0)?; + let spacing = match length_given(p, "spacing", BANNER)? { + None => 1728.0, + Some(v) if v > 0.0 && v <= MAX_DISTANCE => v, + Some(_) => return Err(bad(BANNER, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))), + }; + let diameter = size(p, "diameter", 27.0)?; + let weight = size(p, "weight", 0.3)?; + let with_grommets = switch_or(p, "grommets", true, BANNER)?; + let mut sides = [true; 4]; + for (on, key) in sides.iter_mut().zip(SIDES) { + *on = switch_or(p, key, true, BANNER)?; + } + if !sides.contains(&true) { + return Err(bad(BANNER, "turn on at least one side")); + } + if hem == 0.0 && !with_grommets { + return Err(bad(BANNER, "with no hem and no grommets there is nothing to mark")); + } + let swatch = text_param(p, "swatch", BANNER)?.unwrap_or(REGISTRATION); + let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(BANNER, e))?; + let all = switch_or(p, "allArtboards", s.doc()?.selection.is_empty(), BANNER)?; + let banners: Vec = if all { + s.doc()?.doc.artboards.iter().map(|a| a.rect).collect() + } else { + let roots = selected_roots(s)?; + if roots.is_empty() { + return Err(bad(BANNER, "select the banner, or mark every artboard")); + } + s.doc()?.doc.bounds_of(&roots, false).into_iter().collect() + }; + if banners.is_empty() { + return Err(bad(BANNER, "there is no banner to mark")); + } + let [top, right, bottom, left] = sides; + let mut marks = vec![]; + let mut total = 0; + for r in &banners { + let (w, h) = (r.width(), r.height()); + if !(w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 { + return Err(bad(BANNER, "the banner has no width or height")); + } + // A hem line must stay inside the banner and apart from the hem line on the far side. + if hem > 0.0 && !(fits(hem, h, top, bottom) && fits(hem, w, left, right)) { + return Err(bad(BANNER, "the hem is too deep for the banner")); + } + let centers = if with_grommets { + // The grommets on a side run from `inset` past one end to `inset` before the other, + // stay inside the banner and stay apart from the grommets on the far side. + let runs_fit = (!(top || bottom) || 2.0 * inset <= w) && (!(left || right) || 2.0 * inset <= h); + if !(runs_fit && fits(inset, h, top, bottom) && fits(inset, w, left, right)) { + return Err(bad(BANNER, "the grommet inset is too large for the banner")); + } + grommet_centers(*r, inset, spacing, sides, MAX_GROMMETS - total) + .ok_or_else(|| bad(BANNER, format!("the spacing makes more than {MAX_GROMMETS} grommets")))? + } else { + vec![] + }; + total += centers.len(); + marks.push((hem_lines(*r, hem, sides), centers)); + } + let parent = outside_clips(&s.doc()?.doc, s.doc()?.target_parent()?); + let ap = Appearance::basic(Paint::None, stroke, weight); + let half = diameter / 2.0; + let (ids, hems) = s.edit("Banner Hems and Grommets", |d, sel| { + let (mut ids, mut hems) = (vec![], 0); + for (lines, centers) in &marks { + let mut children = vec![]; + for (a, b) in lines { + let mut n = Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone()); + n.name = Some("Hem".into()); + children.push(Arc::new(n)); + } + hems += lines.len(); + for c in centers { + let ring = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(c.x - half, c.y - half, c.x + half, c.y + half)), ap.clone()); + let across = Node::path(d.alloc_id(), shapes::line(Point::new(c.x - half, c.y), Point::new(c.x + half, c.y)), ap.clone()); + let down = Node::path(d.alloc_id(), shapes::line(Point::new(c.x, c.y - half), Point::new(c.x, c.y + half)), ap.clone()); + let mut g = Node::group(d.alloc_id(), vec![Arc::new(ring), Arc::new(across), Arc::new(down)]); + g.name = Some("Grommet".into()); + children.push(Arc::new(g)); + } + let mut g = Node::group(d.alloc_id(), children); + g.name = Some("Banner Marks".into()); + ids.push(g.id); + d.insert(parent, usize::MAX, g)?; + } + sel.set(ids.iter().copied()); + Ok((ids, hems)) + })?; + let first = ids.first().map(|i| i.0); + Ok(json!({ "id": first, "ids": ids.iter().map(|i| i.0).collect::>(), "hems": hems, "grommets": total })) +} diff --git a/crates/engine/src/cmd/addons/cutting/banner/tests.rs b/crates/engine/src/cmd/addons/cutting/banner/tests.rs new file mode 100644 index 0000000000..b186720b45 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/banner/tests.rs @@ -0,0 +1,263 @@ +//! Tests for `addon.cutting.banner`: hem lines and grommet marks on the sides of a banner. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Appearance, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::{grommet_centers, hem_lines}; +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +/// Runs Banner Hems and Grommets with `p` and gives the first group it made. +fn banner(s: &mut Session, p: Value) -> (Value, Node) { + let r = s.execute("addon.cutting.banner", &p).unwrap(); + let g = node(s, id_of(&r)); + (r, g) +} + +fn round(p: Point) -> (f64, f64) { + let r = |v: f64| (v * 1000.0).round() / 1000.0; + (r(p.x), r(p.y)) +} + +/// The hem lines in group `g` as their two ends. +fn hems(g: &Node) -> Vec<((f64, f64), (f64, f64))> { + g.children() + .unwrap() + .iter() + .filter(|c| c.name.as_deref() == Some("Hem")) + .map(|c| match &c.kind { + NodeKind::Path { path, .. } => { + let a: Vec = path.anchors().map(|(_, _, a)| a.p).collect(); + assert_eq!(a.len(), 2, "a hem line is one straight segment"); + (round(a[0]), round(a[1])) + } + other => panic!("not a path: {other:?}"), + }) + .collect() +} + +/// The grommet marks in group `g` as their centers. +fn grommets(g: &Node) -> Vec<(f64, f64)> { + g.children().unwrap().iter().filter(|c| c.name.as_deref() == Some("Grommet")).map(|c| round(c.geometric_bounds().unwrap().center())).collect() +} + +#[test] +fn hems_and_grommets_go_round_a_selected_banner() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 400.0, 200.0); + let undo = s.doc().unwrap().history.undo.len(); + let (r, g) = banner(&mut s, json!({"hem": 20, "grommetInset": 10, "spacing": 150, "diameter": 8})); + assert_eq!(g.name.as_deref(), Some("Banner Marks")); + assert_eq!( + hems(&g), + vec![((0.0, 20.0), (400.0, 20.0)), ((380.0, 0.0), (380.0, 200.0)), ((0.0, 180.0), (400.0, 180.0)), ((20.0, 0.0), (20.0, 200.0))] + ); + // 380 pt along the top and bottom need 3 gaps at most 150 apart, 180 down the sides 2; the + // corners are shared. + assert_eq!( + grommets(&g), + vec![ + (10.0, 10.0), + (136.667, 10.0), + (263.333, 10.0), + (390.0, 10.0), + (390.0, 100.0), + (390.0, 190.0), + (263.333, 190.0), + (136.667, 190.0), + (10.0, 190.0), + (10.0, 100.0) + ] + ); + assert_eq!(r["hems"], 4); + assert_eq!(r["grommets"], 10); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(s.doc().unwrap().selection.objects, [g.id]); +} + +#[test] +fn a_grommet_is_a_circle_with_a_cross_stroked_in_registration() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let (_, g) = banner(&mut s, json!({"hem": 0, "grommetInset": 10, "diameter": 6, "weight": 0.5})); + let first = g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Grommet")).cloned().unwrap(); + assert_eq!(first.geometric_bounds(), Some(Rect::new(7.0, 7.0, 13.0, 13.0))); + let parts = first.children().unwrap(); + assert_eq!(parts.len(), 3, "a circle and two lines"); + for c in parts.iter() { + assert!(matches!(c.kind, NodeKind::Path { .. }), "a path"); + assert_eq!(c.appearance.fill_paint(), Paint::None); + assert!(c.appearance.stroke_paint().is_registration()); + assert_eq!(c.appearance.stroke_width(), 0.5); + } +} + +#[test] +fn every_artboard_is_marked_when_nothing_is_selected() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 200, "height": 150})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let r = s.execute("addon.cutting.banner", &json!({"grommets": false})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 2); + let second = node(&s, ids[1]); + assert_eq!(hems(&second)[0], ((900.0, 72.0), (1100.0, 72.0)), "the default hem is 72 pt"); + assert!(grommets(&second).is_empty()); + assert_eq!(r["grommets"], 0); +} + +#[test] +fn only_the_chosen_sides_are_marked() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 300.0, 100.0); + let (_, g) = banner(&mut s, json!({"hem": 10, "grommetInset": 5, "spacing": 1000, "right": false, "bottom": false, "left": false})); + assert_eq!(hems(&g), vec![((0.0, 10.0), (300.0, 10.0))]); + assert_eq!(grommets(&g), vec![(5.0, 5.0), (295.0, 5.0)]); +} + +#[test] +fn units_are_read_and_bad_params_are_refused() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 400.0, 400.0); + let (_, g) = banner(&mut s, json!({"hem": "1 in", "grommetInset": "0.5 in", "spacing": "10 in"})); + assert_eq!(hems(&g)[0], ((0.0, 72.0), (400.0, 72.0))); + assert_eq!(grommets(&g)[0], (36.0, 36.0)); + let before = s.doc().unwrap().history.undo.len(); + for p in [ + json!({"hem": -1}), + json!({"hem": "x"}), + json!({"spacing": 0}), + json!({"diameter": 0}), + json!({"weight": 1e9}), + json!({"top": "yes"}), + json!({"top": false, "right": false, "bottom": false, "left": false}), + json!({"hem": 0, "grommets": false}), + json!({"hem": 250}), + json!({"grommetInset": 250}), + json!({"spacing": 0.001}), + json!({"swatch": "no such swatch"}), + ] { + assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), before, "a refused run changes nothing"); +} + +#[test] +fn hems_and_grommets_on_one_side_may_go_past_the_middle() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 300.0, 100.0); + let one = json!({"right": false, "bottom": false, "left": false, "spacing": 1000}); + let with = |extra: Value| { + let mut p = one.clone(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + let (_, g) = banner(&mut s, with(json!({"hem": 60, "grommetInset": 70}))); + assert_eq!(hems(&g), vec![((0.0, 60.0), (300.0, 60.0))]); + assert_eq!(grommets(&g), vec![(70.0, 70.0), (230.0, 70.0)]); + for p in [with(json!({"hem": 100})), with(json!({"hem": 0, "grommetInset": 100})), with(json!({"grommetInset": 151}))] { + assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} reaches the far edge or past the ends"); + } + // A grommet inset of half the width puts the grommets of a side on one point: one grommet. + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 300.0); + let (_, g) = banner(&mut s, with(json!({"hem": 0, "grommetInset": 50, "spacing": 10}))); + assert_eq!(grommets(&g), vec![(50.0, 50.0)]); +} + +#[test] +fn hems_and_grommets_on_opposite_sides_stay_apart() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 300.0, 100.0); + let pair = |p: Value| { + let mut q = json!({"right": false, "left": false, "spacing": 1000}); + q.as_object_mut().unwrap().extend(p.as_object().unwrap().clone()); + q + }; + // Half the height would put the top and bottom marks on the same line. + let refused = [ + pair(json!({"hem": 50})), + pair(json!({"hem": 0, "grommetInset": 50})), + json!({"hem": 0, "grommetInset": 150, "top": false, "bottom": false}), + ]; + for p in refused { + assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused"); + } + let (_, g) = banner(&mut s, pair(json!({"hem": 49.5, "grommetInset": 49.5}))); + assert_eq!(hems(&g), vec![((0.0, 49.5), (300.0, 49.5)), ((0.0, 50.5), (300.0, 50.5))]); + assert_eq!(grommets(&g), vec![(49.5, 49.5), (250.5, 49.5), (250.5, 50.5), (49.5, 50.5)]); +} + +#[test] +fn the_marks_go_outside_a_clip_group_edited_in_isolation() { + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let (g, art) = s + .edit("clip group", |d, _| { + let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default())); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 200.0)), Appearance::default()); + let art_id = art.id; + let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(art)]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + let id = g.id; + d.insert(parent, usize::MAX, g)?; + Ok((id, art_id)) + }) + .unwrap(); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + let (_, marks) = banner(&mut s, json!({"hem": 20, "grommets": false})); + // The clipping path would hide marks inside the clip group. + assert_eq!(s.doc().unwrap().doc.parent_of(marks.id), parent); + assert_eq!(hems(&marks)[0], ((0.0, 20.0), (400.0, 20.0)), "the selected art is the banner"); +} + +#[test] +fn all_artboards_marks_the_artboards_even_with_a_selection() { + let mut s = session(); + rect(&mut s, 10.0, 10.0, 100.0, 100.0); + let (_, g) = banner(&mut s, json!({"allArtboards": true, "grommets": false})); + assert_eq!(hems(&g)[0], ((0.0, 72.0), (800.0, 72.0))); +} + +#[test] +fn an_empty_document_has_no_banner_to_mark_when_artboards_are_off() { + let mut s = session(); + assert!(s.execute("addon.cutting.banner", &json!({"allArtboards": false})).is_err()); +} + +#[test] +fn a_side_shorter_than_the_spacing_has_a_grommet_at_each_end() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let c = grommet_centers(r, 0.0, 1000.0, [true, false, false, false], 10).unwrap(); + assert_eq!(c, vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)]); + // Spacing that divides the side exactly does not add a grommet, even when the division + // rounds up (2.1 / 0.3 is 7.000000000000001). + let c = grommet_centers(Rect::new(0.0, 0.0, 2.1, 1.0), 0.0, 0.3, [true, false, false, false], 100).unwrap(); + assert_eq!(c.len(), 8); + assert!(grommet_centers(r, 0.0, 1.0, [true; 4], 10).is_none(), "over the limit"); + assert!(hem_lines(r, 0.0, [true; 4]).is_empty()); +} diff --git a/crates/engine/src/cmd/addons/cutting/weed.rs b/crates/engine/src/cmd/addons/cutting/weed.rs index 8a6375a595..183a66ad57 100644 --- a/crates/engine/src/cmd/addons/cutting/weed.rs +++ b/crates/engine/src/cmd/addons/cutting/weed.rs @@ -72,7 +72,7 @@ fn lines_across(length: f64, count: Option, spacing: Option) -> Resu /// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping /// sublayer (a group edited in isolation), the container around the outermost of them. A clipping /// path would hide the lines and the box, which lie outside the art. -fn outside_clips(d: &Document, parent: Option) -> Option { +pub(super) fn outside_clips(d: &Document, parent: Option) -> Option { let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })); let (mut at, mut out) = (parent, parent); while let Some(p) = at { diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 9ddcc43559..8a271557a9 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -2,8 +2,8 @@ //! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's //! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners //! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), the Shared Edge Merge Tool's -//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes and Expand Dashes -//! into Paths. +//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes, Expand Dashes +//! into Paths and Trim at Crossings ([`trim`]). //! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's //! and the UI's. @@ -14,6 +14,7 @@ mod shape; mod shared_edge; #[cfg(test)] mod tests; +mod trim; use std::collections::{BTreeMap, BTreeSet}; @@ -143,7 +144,7 @@ pub fn specs() -> Vec { "Add Points at Crossings", ["Object", "Plug-ins", "Path Precision Tools"], None, - "{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}", + "{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too, a curved segment that loops over itself included), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}", has_selection, crossings ), @@ -178,6 +179,7 @@ pub fn specs() -> Vec { v.extend(shape::specs()); v.extend(corners::specs()); v.extend(shared_edge::specs()); + v.extend(trim::specs()); v } @@ -609,7 +611,7 @@ fn remove_keep_shape(s: &mut Session, _: &Value) -> Result { fn picked_segments(segments: Option<&BTreeSet<(usize, usize)>>, si: usize, sp: &SubPath, anchors: &BTreeSet) -> BTreeSet { let n = sp.anchors.len().max(1); match segments.filter(|set| !set.is_empty()) { - Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(), + Some(set) => super::super::path::subpath_refs(set, si).collect(), None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n))).collect(), } } @@ -681,6 +683,18 @@ pub(super) fn close(s: &mut Session, p: &Value) -> Result { // ---------- Split / Add Points at Crossings ---------- +/// The crossing places `on` of one path, as `(segment, t)` for each of its `subpaths` subpaths in +/// the order they come in `on`. A place on a subpath past the last is left out. +fn places_by_subpath(on: &[(usize, usize, f64)], subpaths: usize) -> Vec> { + let mut by_sub = vec![vec![]; subpaths]; + for &(si, seg, t) in on { + if let Some(here) = by_sub.get_mut(si) { + here.push((seg, t)); + } + } + by_sub +} + fn crossings(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.pathtools.crossings"; let (cut, self_crossings) = (switch_or(p, "cut", false, C)?, switch_or(p, "selfCrossings", false, C)?); @@ -702,8 +716,7 @@ fn crossings(s: &mut Session, p: &Value) -> Result { } let mut subs = vec![]; let mut marks = vec![]; - for (si, sp) in path.subpaths.iter().enumerate() { - let here: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect(); + for (sp, here) in path.subpaths.iter().zip(places_by_subpath(&on, path.subpaths.len())) { let (out, m) = pt::insert_at(sp, &here); count += m.len(); subs.push(out); diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index e31aadf56e..f98880e204 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -6,7 +6,7 @@ use std::collections::BTreeSet; use serde_json::{Value, json}; use vectorcraft_doc::{LiveCorners, LiveShape, NodeId}; use vectorcraft_geom::shapes::CornerKind; -use vectorcraft_geom::{AnchorKind, PathData, Point}; +use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath}; use vectorcraft_tools::{PointerEvent, PointerKind}; use crate::{Session, ViewInfo}; @@ -275,6 +275,152 @@ fn crossings_add_anchors_or_cut_in_one_step() { refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]); } +/// A selected path with an open polyline subpath through each of `subs`; an empty list makes an +/// empty subpath. +fn compound(s: &mut Session, subs: &[&[(f64, f64)]]) -> NodeId { + let id = line(s, &[(0.0, 0.0), (1.0, 0.0)], false); + let pd = + PathData::new(subs.iter().map(|pts| SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), false)).collect()); + s.edit("Test", |d, _| { + super::set_path(d, id, pd); + Ok(()) + }) + .unwrap(); + select(s, &[id]); + id +} + +/// The anchor points of each subpath of `pd`, rounded to a millionth. +fn points(pd: &PathData) -> Vec> { + let r = |v: f64| (v * 1e6).round() / 1e6; + pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| (r(a.p.x), r(a.p.y))).collect()).collect() +} + +/// A path's subpaths: A (0), an empty one (1), C (2) and D (3, the last). +const SUBS: [&[(f64, f64)]; 4] = + [&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], &[], &[(0.0, 50.0), (100.0, 50.0)], &[(0.0, 100.0), (100.0, 100.0), (200.0, 100.0)]]; + +/// Direct-select `segments` of `id` and the anchors `anchors`. +fn select_segments(s: &mut Session, id: NodeId, anchors: &[(usize, usize)], segments: &[(usize, usize)]) { + s.select(|_, sel| { + sel.set([id]); + sel.anchors.insert(id, anchors.iter().copied().collect()); + sel.segments.insert(id, segments.iter().copied().collect()); + }) + .unwrap(); +} + +#[test] +fn picked_segments_reads_only_its_own_subpath() { + let set: BTreeSet<(usize, usize)> = [(0, 0), (0, 2), (1, 1), (3, 0), (3, 1)].into(); + let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)], false); + let all: BTreeSet = (0..4).collect(); + let picked = |si| super::picked_segments(Some(&set), si, &sp, &all).into_iter().collect::>(); + assert_eq!([picked(0), picked(1), picked(2), picked(3), picked(4)], [vec![0, 2], vec![1], vec![], vec![0, 1], vec![]]); + // Without direct-selected segments, the segments between two selected anchors. + let anchors: BTreeSet = [0, 1, 3].into(); + for none in [None, Some(&BTreeSet::new())] { + assert_eq!(super::picked_segments(none, 0, &sp, &anchors).into_iter().collect::>(), [0]); + } +} + +#[test] +fn places_by_subpath_keeps_the_order_and_leaves_out_missing_subpaths() { + let on = [(3, 1, 0.75), (0, 0, 0.5), (3, 1, 0.25), (7, 0, 0.5), (0, 1, 0.1), (3, 0, 0.5), (0, 0, 0.2)]; + let by_sub = super::places_by_subpath(&on, 4); + assert_eq!(by_sub, [vec![(0, 0.5), (1, 0.1), (0, 0.2)], vec![], vec![], vec![(1, 0.75), (1, 0.25), (0, 0.5)]]); + // The same places as filtering the whole list for each subpath. + for (si, here) in by_sub.iter().enumerate() { + let filtered: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect(); + assert_eq!(*here, filtered); + } + assert!(super::places_by_subpath(&on, 0).is_empty()); +} + +#[test] +fn add_points_adds_to_the_selected_segments_of_each_subpath() { + let mut s = session(); + let c = compound(&mut s, &SUBS); + let before = path(&s, c); + // The first segment of A and the second of D; a segment of C without its anchors selected. + select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(2)); + let want = vec![ + vec![(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (200.0, 0.0)], + vec![], + vec![(0.0, 50.0), (100.0, 50.0)], + vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)], + ]; + let after = path(&s, c); + assert_eq!(points(&after), want); + assert_eq!(steps(&s), n + 1); + let st = s.doc().unwrap(); + assert_eq!((st.selection.objects.clone(), st.selection.anchors.get(&c), st.selection.segments.get(&c)), (vec![c], None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, c), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path(&s, c), after); + // Without selected segments: those between two selected anchors, here on the last subpath only. + for segments in [None, Some(BTreeSet::new())] { + s.execute("edit.undo", &json!({})).unwrap(); + s.select(|_, sel| { + sel.set([c]); + sel.anchors.insert(c, [(0, 0), (3, 1), (3, 2)].into()); + sel.segments.remove(&c); + if let Some(set) = segments.clone() { + sel.segments.insert(c, set); + } + }) + .unwrap(); + assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(1)); + assert_eq!( + points(&path(&s, c)), + [points(&before)[..3].to_vec(), vec![vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)]]].concat() + ); + } +} + +#[test] +fn crossings_add_anchors_on_each_subpath_of_a_compound_path() { + let mut s = session(); + // A and the last subpath cross both lines; the empty subpath and C, which ends short of them, do not. + let c = compound(&mut s, &[&[(0.0, 50.0), (200.0, 50.0)], &[], &[(0.0, 150.0), (40.0, 150.0)], &[(0.0, 250.0), (400.0, 250.0)]]); + let q = line(&mut s, &[(100.0, 0.0), (100.0, 300.0)], false); + let r = line(&mut s, &[(150.0, 0.0), (150.0, 300.0)], false); + select(&mut s, &[c, q, r]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.crossings", &json!({})).unwrap()["crossings"], json!(8)); + let want = vec![ + vec![(0.0, 50.0), (100.0, 50.0), (150.0, 50.0), (200.0, 50.0)], + vec![], + vec![(0.0, 150.0), (40.0, 150.0)], + vec![(0.0, 250.0), (100.0, 250.0), (150.0, 250.0), (400.0, 250.0)], + ]; + assert_eq!(points(&path(&s, c)), want); + assert_eq!(points(&path(&s, q)), [vec![(100.0, 0.0), (100.0, 50.0), (100.0, 250.0), (100.0, 300.0)]]); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + // Cut: the compound path keeps its pieces as subpaths, in order; each line becomes three paths. + let out = ids(&s.execute("addon.pathtools.crossings", &json!({"cut": true})).unwrap()); + assert_eq!(out.len(), 7); + assert_eq!(out[0], c); + let pieces = vec![ + vec![(0.0, 50.0), (100.0, 50.0)], + vec![(100.0, 50.0), (150.0, 50.0)], + vec![(150.0, 50.0), (200.0, 50.0)], + vec![], + vec![(0.0, 150.0), (40.0, 150.0)], + vec![(0.0, 250.0), (100.0, 250.0)], + vec![(100.0, 250.0), (150.0, 250.0)], + vec![(150.0, 250.0), (400.0, 250.0)], + ]; + assert_eq!(points(&path(&s, c)), pieces); + let qs: Vec>> = out[1..4].iter().map(|id| points(&path(&s, *id))).collect(); + assert_eq!(qs, [[vec![(100.0, 0.0), (100.0, 50.0)]], [vec![(100.0, 50.0), (100.0, 250.0)]], [vec![(100.0, 250.0), (100.0, 300.0)]]]); + assert_eq!(steps(&s), n + 1); +} + // ---------- Parametric Shape Tool ---------- /// The parametric shape object `id` is now, as the tool reads it. @@ -887,6 +1033,35 @@ fn divide_into_dashes_divides_only_the_selected_segments() { assert_eq!(layer, pieces, "in the path's place, in order"); } +#[test] +fn divide_into_dashes_divides_the_selected_segments_of_each_subpath() { + let mut s = session(); + let c = compound(&mut s, &SUBS); + let before = path(&s, c); + // The first segment of A and the second of D; a segment of C without its anchors selected. + select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]); + let n = steps(&s); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap(); + assert_eq!(steps(&s), n + 1); + assert_eq!(r["dashes"], json!(8)); + let made: Vec>> = ids(&r).iter().map(|id| points(&path(&s, *id))).collect(); + let piece = |x0: f64, x1: f64, y: f64| vec![vec![(x0, y), (x1, y)]]; + let mut want = vec![vec![vec![], vec![(0.0, 50.0), (100.0, 50.0)]]]; + want.extend([(0.0, 25.0), (25.0, 50.0), (50.0, 75.0), (75.0, 100.0)].map(|(a, b)| piece(a, b, 0.0))); + want.push(vec![vec![(100.0, 0.0), (200.0, 0.0)]]); + want.push(vec![vec![(0.0, 100.0), (100.0, 100.0)]]); + want.extend([(100.0, 125.0), (125.0, 150.0), (150.0, 175.0), (175.0, 200.0)].map(|(a, b)| piece(a, b, 100.0))); + assert_eq!(made, want, "what stays of the path, then each subpath's pieces in order"); + assert_eq!(s.doc().unwrap().selection.objects, ids(&r)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, c), before); + // With no segments selected: those between two selected anchors, here on the last subpath only. + select_segments(&mut s, c, &[(0, 0), (3, 1), (3, 2)], &[]); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap(); + assert_eq!((ids(&r).len(), r["dashes"].clone()), (6, json!(4))); + assert_eq!(points(&path(&s, ids(&r)[0])), points(&before)[..3]); +} + #[test] fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() { let mut s = session(); diff --git a/crates/engine/src/cmd/addons/pathtools/trim.rs b/crates/engine/src/cmd/addons/pathtools/trim.rs new file mode 100644 index 0000000000..134edc6607 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/trim.rs @@ -0,0 +1,321 @@ +//! Trim at Crossings: remove the stretch of a path between the places where other paths, or the +//! path itself, cross it. The command takes the stretch at a point, every stretch a rectangle +//! touches, or every stretch a line through some points crosses. It works on every path that can +//! be edited, selected or not. + +use std::collections::HashSet; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, NodeId, NodeKind}; +use vectorcraft_geom::kurbo::{Line, PathSeg}; +use vectorcraft_geom::{PathData, Point, Rect, SubPath, hit}; +use vectorcraft_pathops::addons::pathtools as pt; + +use super::super::super::draw2::replace_with_pieces; +use super::super::super::pathops::Beside; +use super::super::super::{CommandSpec, bad, cmd, finite_numbers, has_doc, number_or}; +use crate::{Result, Session}; + +#[cfg(test)] +mod tests; + +/// The undo step's name. +const LABEL: &str = "Trim at Crossings"; +/// How near `at` a path counts as under it by default (pt), and the most. +const TOLERANCE: f64 = 3.0; +const MAX_TOLERANCE: f64 = 100.0; +/// Most points of `points`. +const MAX_POINTS: usize = 100_000; +/// The farthest coordinate the command takes. +const MAX_COORD: f64 = 1e7; + +pub(super) fn specs() -> Vec { + vec![cmd!( + "addon.pathtools.trim", + "Trim at Crossings", + [], + None, + "{at: [x, y], tolerance?: pt 0..100 (3; how near `at` a path counts as under it)} or {rect: [x, y, width, height]} or {points: [[x, y]…] (2 to 100000 points)} remove stretches of paths, a stretch running between two neighboring places where the path is crossed by another path or by itself, or from such a place to an open end: `at` removes the stretch of the nearest path at that point, `rect` every stretch the rectangle touches, `points` every stretch the line through the points crosses. Give exactly one of the three. Every path that can be edited takes part, selected or not, compound path members included; hidden and locked paths, guides and clipping paths (compound ones' members included) are neither trimmed nor counted as crossing; in isolation mode only paths inside the isolated object are trimmed. A path that ends on another marks a place on the other; paths that lie along each other don't cross. A closed path that loses a stretch becomes open, an open path that loses a middle stretch becomes two paths with its appearance in its place in the stacking order, and a path nothing crosses is removed whole; a live shape becomes a plain path. One undo step, none when nothing is removed → {ids (the paths left of those trimmed), removed (stretches)}", + has_doc, + trim + )] +} + +/// What picks the stretches to remove. +enum Pick { + /// The stretch of the nearest path within the tolerance of the point. + At(Point, f64), + /// Every stretch the rectangle touches. + Rect(Rect), + /// Every stretch the line through the points crosses. + Line(Vec, Rect), +} + +fn pick(p: &Value) -> Result { + const C: &str = "addon.pathtools.trim"; + let given = |key: &str| p.get(key).filter(|v| !v.is_null()); + let within = |v: &[f64]| v.iter().all(|x| x.abs() <= MAX_COORD); + match (given("at"), given("rect"), given("points")) { + (Some(at), None, None) => { + let [x, y] = finite_numbers(at).filter(|v| within(v)).ok_or_else(|| bad(C, "`at` must be a point [x, y]"))?; + let tolerance = number_or(p, "tolerance", TOLERANCE, C)?; + if !(0.0..=MAX_TOLERANCE).contains(&tolerance) { + return Err(bad(C, "`tolerance` must be from 0 to 100 pt")); + } + Ok(Pick::At(Point::new(x, y), tolerance)) + } + (None, Some(rect), None) => { + let [x, y, w, h] = finite_numbers(rect).filter(|v| within(v)).ok_or_else(|| bad(C, "`rect` must be [x, y, width, height]"))?; + Ok(Pick::Rect(Rect::new(x, y, x + w, y + h).abs())) + } + (None, None, Some(points)) => { + let list = points.as_array().ok_or_else(|| bad(C, "`points` must be a list of [x, y] points"))?; + if !(2..=MAX_POINTS).contains(&list.len()) { + return Err(bad(C, "`points` must hold from 2 to 100000 points")); + } + let pts = list + .iter() + .map(|v| { + finite_numbers(v) + .filter(|xy| within(xy)) + .map(|[x, y]| Point::new(x, y)) + .ok_or_else(|| bad(C, format!("bad point {v}: expected [x, y]"))) + }) + .collect::>>()?; + let bounds = pts.iter().fold(None, |r: Option, q| Some(r.map_or(Rect::from_points(*q, *q), |r| r.union_pt(*q)))); + Ok(Pick::Line(pts, bounds.unwrap_or_default())) + } + _ => Err(bad(C, "give exactly one of `at`, `rect` and `points`")), + } +} + +/// A path that takes part: its id, geometry and control-point box. +type Part<'a> = (NodeId, &'a PathData, Rect); + +/// The paths that take part, bottom to top: the ones that can be edited, without guides and +/// clipping paths. The clipping path of a clip group or a clipped layer is left out whole, the +/// members of a compound clipping path included. +fn parts(d: &Document) -> Vec> { + let editable = d.editable(); + let mut masks = HashSet::new(); + d.walk(|n| { + if n.clips() + && let Some(first) = n.children().and_then(|c| c.first()) + { + first.walk(&mut |m| { + masks.insert(m.id); + }); + } + }); + let mut out = vec![]; + d.walk(|n| { + if let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind + && !masks.contains(&n.id) + && let Some(b) = path.control_bounds() + && editable(n.id) + { + out.push((n.id, path, b)); + } + }); + out +} + +/// Most box tests one command makes between a line's pieces and the paths' segments. +const MAX_LINE_TESTS: usize = 60_000_000; +/// Most tests one command makes between the boxes of the paths to trim and of the other paths. +const MAX_BOX_TESTS: usize = 50_000_000; + +/// Does the line through `pts` cross `sp`? Each piece of the line compared costs one of `budget`; +/// `None` once it is spent. +fn crossed_by_line(sp: &SubPath, pts: &[Point], budget: &mut usize) -> Option { + let segs: Vec<_> = (0..sp.segment_count()) + .map(|i| { + let c = sp.segment(i); + (c, Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3).inflate(1e-9, 1e-9)) + }) + .collect(); + let Some(reach) = segs.iter().map(|s| s.1).reduce(|a, b| a.union(b)) else { return Some(false) }; + for w in pts.windows(2) { + let [a, b] = w else { continue }; + let piece = Rect::from_points(*a, *b); + *budget = budget.checked_sub(1)?; + if !reach.overlaps(piece) { + continue; + } + for (c, r) in &segs { + *budget = budget.checked_sub(1)?; + if r.overlaps(piece) && !PathSeg::Cubic(*c).intersect_line(Line::new(*a, *b)).is_empty() { + return Some(true); + } + } + } + Some(false) +} + +/// Does the rectangle or line of `pick` touch the outline of `piece`? Never for `Pick::At`. +fn touches(piece: &SubPath, pick: &Pick, budget: &mut usize) -> Result { + match pick { + Pick::At(..) => Ok(false), + Pick::Rect(r) => Ok(hit::intersects_rect(&PathData::single(piece.clone()), *r)), + Pick::Line(pts, _) => crossed_by_line(piece, pts, budget) + .ok_or_else(|| bad("addon.pathtools.trim", "the line is too long, or the paths it passes too many, to check")), + } +} + +/// `path` without the stretches `pick` takes (`on`: the place of `Pick::At` on it): its subpaths +/// then, and how many stretches went. `None` when none does. A rectangle or a line that touches no +/// part of `path` leaves it out before its crossings are looked for. +fn trimmed( + path: &PathData, + others: &[&PathData], + pick: &Pick, + on: Option, + budget: &mut usize, + work: &mut pt::Work, +) -> Result, usize)>> { + const C: &str = "addon.pathtools.trim"; + let reached = match pick { + Pick::At(..) => true, + Pick::Rect(r) => hit::intersects_rect(path, *r), + Pick::Line(..) => { + let mut any = false; + for sp in &path.subpaths { + if touches(sp, pick, budget)? { + any = true; + break; + } + } + any + } + }; + if !reached { + return Ok(None); + } + let places = + pt::crossings_on_within(path, others, work).ok_or_else(|| bad(C, "the paths are too many or too tangled to find where they cross"))?; + let mut subs = vec![]; + let mut removed = 0; + for (si, sp) in path.subpaths.iter().enumerate() { + // The places come in subpath order: this subpath's are one run of them. + let run = places.partition_point(|p| p.0 < si)..places.partition_point(|p| p.0 <= si); + let here: Vec<(usize, f64)> = places.get(run).unwrap_or_default().iter().map(|&(_, seg, t)| (seg, t)).collect(); + let spans = pt::spans(sp, &here); + let marks: Vec = match pick { + Pick::At(..) => { + let at = on.filter(|(s, ..)| *s == si).and_then(|(_, seg, t)| pt::span_at(sp, &spans, seg, t)); + (0..spans.len()).map(|i| Some(i) == at).collect() + } + _ => spans.iter().map(|s| touches(&pt::slice(sp, *s), pick, budget)).collect::>()?, + }; + match pt::without_spans(sp, &spans, &marks) { + Some(pieces) => { + removed += marks.iter().filter(|m| **m).count(); + subs.extend(pieces); + } + None => subs.push(sp.clone()), + } + } + Ok((removed > 0).then_some((subs, removed))) +} + +fn trim(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.trim"; + let pick = pick(p)?; + let st = s.doc()?; + let d = &st.doc; + let parts = parts(d); + // In isolation mode only the isolated object's paths are trimmed; paths outside it still cross them. + let root = crate::cmd::ordinary_isolation(st); + let in_scope = |id: NodeId| root.is_none_or(|r| id == r || crate::cmd::inside_isolation(d, r, id)); + // The paths to trim, each with the place `at` falls on. + let targets: Vec<(usize, Option)> = match &pick { + Pick::At(at, tolerance) => { + let mut best: Option<(usize, pt::Place, f64)> = None; + for (i, (id, path, b)) in parts.iter().enumerate() { + if !in_scope(*id) { + continue; + } + let b = b.inflate(*tolerance, *tolerance); + if !(b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1) { + continue; + } + // Of paths equally near, the one on top. + if let Some((si, seg, t, _, dist)) = path.nearest(*at) + && dist <= *tolerance + && best.is_none_or(|b| dist <= b.2) + { + best = Some((i, (si, seg, t), dist)); + } + } + best.map(|(i, on, _)| (i, Some(on))).into_iter().collect() + } + Pick::Rect(r) | Pick::Line(_, r) => { + parts.iter().enumerate().filter(|(_, (id, _, b))| in_scope(*id) && b.overlaps(*r)).map(|(i, _)| (i, None)).collect() + } + }; + // Each path to trim has its box tested against every other path's. + if targets.len().saturating_mul(parts.len()) > MAX_BOX_TESTS { + return Err(bad(C, "the rectangle or line reaches too many paths to check")); + } + let mut plan: Vec<(NodeId, Vec)> = vec![]; + let mut removed = 0; + let mut budget = MAX_LINE_TESTS; + // One budget of crossing work for every path the command trims. + let mut work = pt::Work::default(); + for (i, on) in targets { + let Some((id, path, b)) = parts.get(i) else { continue }; + let reach = b.inflate(1e-3, 1e-3); + let others: Vec<&PathData> = + parts.iter().enumerate().filter(|(j, (_, _, ob))| *j != i && reach.overlaps(*ob)).map(|(_, (_, o, _))| *o).collect(); + let Some((subs, n)) = trimmed(path, &others, &pick, on, &mut budget, &mut work)? else { continue }; + removed += n; + // A path of one subpath becomes a path per piece; the pieces of one with more stay its subpaths. + let pieces = match (path.subpaths.len(), subs.is_empty()) { + (_, true) => vec![], + (1, false) => subs.into_iter().map(PathData::single).collect(), + _ => vec![PathData::new(subs)], + }; + plan.push((*id, pieces)); + } + if plan.is_empty() { + return Ok(json!({ "ids": [], "removed": 0 })); + } + let ids = s.edit(LABEL, |d, sel| { + let trimmed: Vec = plan.iter().map(|(id, _)| *id).collect(); + // The compound paths that may lose their last member. + let mut compounds: Vec = trimmed + .iter() + .filter_map(|id| d.parent_of(*id)) + .filter(|c| matches!(d.node(*c).map(|n| &n.kind), Some(NodeKind::Compound { .. }))) + .collect(); + compounds.dedup(); + let mut beside = Beside::replacing(d, &trimmed); + let mut all = vec![]; + for (id, pieces) in plan { + let selected = sel.contains(id); + sel.anchors.remove(&id); + sel.segments.remove(&id); + let made = replace_with_pieces(d, &mut beside, id, pieces)?; + // The pieces of a selected path are selected; what was not selected stays so. + if made.is_empty() { + sel.remove(id); + } else if selected { + for n in &made { + if !sel.contains(*n) { + sel.add(*n); + } + } + } + all.extend(made); + } + beside.finish(d)?; + for c in compounds { + if d.node(c).is_some_and(|n| n.children().is_some_and(|ch| ch.is_empty())) { + d.remove(c)?; + sel.remove(c); + } + } + Ok(all) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "removed": removed })) +} diff --git a/crates/engine/src/cmd/addons/pathtools/trim/tests.rs b/crates/engine/src/cmd/addons/pathtools/trim/tests.rs new file mode 100644 index 0000000000..42adb70ab9 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/trim/tests.rs @@ -0,0 +1,527 @@ +//! Trim at Crossings: the stretch at a point, the stretches a rectangle touches or a line crosses, +//! one undo step, and what takes no part. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_geom::{PathData, Point}; + +use crate::Session; + +const TRIM: &str = "addon.pathtools.trim"; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId { + let anchors: Vec = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + let id = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + id +} + +/// A line across at y = 100 from x = 0 to 300, and two uprights across it at x = 100 and x = 200. +fn across_and_uprights(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let h = line(s, &[(0.0, 100.0), (300.0, 100.0)], false); + let v1 = line(s, &[(100.0, 50.0), (100.0, 150.0)], false); + let v2 = line(s, &[(200.0, 50.0), (200.0, 150.0)], false); + (h, v1, v2) +} + +fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +fn exists(s: &Session, id: NodeId) -> bool { + s.doc().unwrap().doc.node(id).is_some() +} + +/// The first and last anchor of the path's only subpath. +fn ends(s: &Session, id: NodeId) -> (Point, Point) { + let p = path(s, id); + assert_eq!(p.subpaths.len(), 1); + (p.subpaths[0].anchors.first().unwrap().p, p.subpaths[0].anchors.last().unwrap().p) +} + +fn near(p: Point, x: f64, y: f64) -> bool { + p.distance(Point::new(x, y)) < 1e-6 +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect() +} + +/// The ids of the first layer's objects, bottom to top. +fn stack(s: &Session) -> Vec { + s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect() +} + +fn set_flags(s: &mut Session, id: NodeId, locked: bool, visible: bool) { + s.edit("flags", |d, _| { + let n = d.node_mut(id).unwrap(); + n.locked = locked; + n.visible = visible; + Ok(()) + }) + .unwrap(); +} + +#[test] +fn a_point_between_two_crossings_removes_that_stretch_and_splits_the_path() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + let look = s.doc().unwrap().doc.node(h).unwrap().appearance.clone(); + let (before, n) = (stack(&s), steps(&s)); + let r = s.execute(TRIM, &json!({"at": [150, 101]})).unwrap(); + assert_eq!(r["removed"], json!(1)); + let made = ids(&r); + assert_eq!(made.len(), 2, "{r}"); + assert_eq!(made[0], h, "the first piece keeps the id"); + let (a, b) = (ends(&s, made[0]), ends(&s, made[1])); + assert!(near(a.0, 0.0, 100.0) && near(a.1, 100.0, 100.0), "{a:?}"); + assert!(near(b.0, 200.0, 100.0) && near(b.1, 300.0, 100.0), "{b:?}"); + // Both pieces look as the path did and stand where it stood, under the uprights. + assert!(made.iter().all(|id| s.doc().unwrap().doc.node(*id).unwrap().appearance == look)); + assert_eq!(stack(&s), [made[0], made[1], v1, v2]); + assert_eq!(before, [h, v1, v2]); + // The uprights are as they were, nothing got selected, and it is one undo step. + assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2)); + assert!(s.doc().unwrap().selection.is_empty()); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(stack(&s), before); + assert!(near(ends(&s, h).1, 300.0, 100.0)); +} + +#[test] +fn an_end_stretch_runs_to_the_open_end_and_the_rest_stays_one_path() { + let mut s = session(); + let (h, ..) = across_and_uprights(&mut s); + let r = s.execute(TRIM, &json!({"at": [40, 100]})).unwrap(); + assert_eq!((ids(&r), r["removed"].clone()), (vec![h], json!(1))); + let e = ends(&s, h); + assert!(near(e.0, 100.0, 100.0) && near(e.1, 300.0, 100.0), "{e:?}"); + // No anchor is added at the crossing the rest runs through. + assert_eq!(path(&s, h).anchor_count(), 2); + // A point on no path within the tolerance removes nothing and makes no step. + let n = steps(&s); + assert_eq!(s.execute(TRIM, &json!({"at": [250, 110]})).unwrap(), json!({"ids": [], "removed": 0})); + assert_eq!(s.execute(TRIM, &json!({"at": [250, 110], "tolerance": 0})).unwrap()["removed"], json!(0)); + assert_eq!(steps(&s), n); + assert_eq!(s.execute(TRIM, &json!({"at": [250, 110], "tolerance": 12})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).1, 200.0, 100.0)); +} + +#[test] +fn a_closed_path_that_loses_a_stretch_opens() { + let mut s = session(); + let sq = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + line(&mut s, &[(50.0, 150.0), (250.0, 150.0)], false); + // The top half of the square's outline, between the two places the line crosses it. + let r = s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(); + assert_eq!((ids(&r), r["removed"].clone()), (vec![sq], json!(1))); + let p = path(&s, sq); + assert_eq!(p.subpaths.len(), 1); + assert!(!p.subpaths[0].closed); + let pts: Vec = p.subpaths[0].anchors.iter().map(|a| a.p).collect(); + assert_eq!(pts.len(), 4, "{pts:?}"); + assert!(pts.iter().all(|q| q.y >= 150.0 - 1e-6), "only the lower half is left: {pts:?}"); + assert!(pts.iter().any(|q| near(*q, 100.0, 200.0)) && pts.iter().any(|q| near(*q, 200.0, 200.0))); + // It is a plain path now. + assert!(matches!(&s.doc().unwrap().doc.node(sq).unwrap().kind, NodeKind::Path { live: None, .. })); +} + +#[test] +fn a_rectangle_removes_every_stretch_it_touches_as_one_step() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + let h2 = line(&mut s, &[(0.0, 120.0), (300.0, 120.0)], false); + let n = steps(&s); + // Over the middle of both lines across, touching neither upright. + let r = s.execute(TRIM, &json!({"rect": [140, 90, 20, 40]})).unwrap(); + assert_eq!(r["removed"], json!(2)); + assert_eq!(ids(&r).len(), 4); + assert_eq!(steps(&s), n + 1, "one undo step"); + assert!(near(ends(&s, h).1, 100.0, 100.0) && near(ends(&s, h2).1, 100.0, 120.0)); + assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(ends(&s, h).1, 300.0, 100.0) && near(ends(&s, h2).1, 300.0, 120.0)); + // A rectangle given from its far corner is the same one; one over empty space removes nothing. + assert_eq!(s.execute(TRIM, &json!({"rect": [160, 130, -20, -40]})).unwrap()["removed"], json!(2)); + let n = steps(&s); + assert_eq!(s.execute(TRIM, &json!({"rect": [300, 200, 50, 50]})).unwrap()["removed"], json!(0)); + assert_eq!(steps(&s), n); + // An upright's stretches: above the upper line, between the two, below the lower. A rectangle + // over its top end takes the first only. + assert_eq!(s.execute(TRIM, &json!({"rect": [95, 45, 10, 10]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, v1).0, 100.0, 100.0) && near(ends(&s, v1).1, 100.0, 150.0)); +} + +#[test] +fn a_line_through_points_removes_the_stretches_it_crosses() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + let n = steps(&s); + // Down through the middle of the line across, then right through the lower end of the second upright. + let r = s.execute(TRIM, &json!({"points": [[150, 80], [150, 130], [250, 130]]})).unwrap(); + assert_eq!(r["removed"], json!(2)); + assert_eq!(steps(&s), n + 1); + assert!(near(ends(&s, h).1, 100.0, 100.0)); + assert!(near(ends(&s, v2).0, 200.0, 50.0) && near(ends(&s, v2).1, 200.0, 100.0)); + assert_eq!(path(&s, v1).anchor_count(), 2); +} + +#[test] +fn locked_and_hidden_paths_are_not_trimmed_and_do_not_count_as_crossing() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + set_flags(&mut s, v1, true, true); + set_flags(&mut s, v2, false, false); + let n = steps(&s); + // On the locked upright, and a rectangle over the hidden one's top end: nothing goes. + assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0)); + assert_eq!(s.execute(TRIM, &json!({"rect": [195, 45, 10, 10]})).unwrap()["removed"], json!(0)); + assert_eq!(steps(&s), n); + assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2)); + // Neither marks a place on the line across: with nothing else crossing it, it goes whole. + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(), json!({"ids": [], "removed": 1})); + assert!(!exists(&s, h) && exists(&s, v1) && exists(&s, v2)); + // A guide doesn't count either. + s.execute("edit.undo", &json!({})).unwrap(); + set_flags(&mut s, v1, false, true); + s.execute("select.set", &json!({"ids": [v1.0]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(); + assert!(!exists(&s, h)); +} + +#[test] +fn a_path_of_two_subpaths_loses_the_stretch_on_the_subpath_under_the_point() { + let mut s = session(); + // Lines of different lengths, so the places on one fall at other parameters than on the other. + let line_at = |x0: f64, x1: f64, y: f64| vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y), Point::new(x1, y)], false); + let two = s + .edit("two", |d, _| { + let layer = d.layers[0].id; + let id = d.alloc_id(); + let both = PathData::new(vec![line_at(0.0, 300.0, 100.0), line_at(50.0, 250.0, 120.0)]); + d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, both, Default::default()))?; + Ok(id) + }) + .unwrap(); + line(&mut s, &[(100.0, 50.0), (100.0, 150.0)], false); + line(&mut s, &[(200.0, 50.0), (200.0, 150.0)], false); + // The middle of the lower subpath goes; the upper one stays whole, both in the one path. + assert_eq!(s.execute(TRIM, &json!({"at": [150, 120]})).unwrap()["removed"], json!(1)); + let p = path(&s, two); + let spans: Vec<(Point, Point)> = p.subpaths.iter().map(|sp| (sp.anchors[0].p, sp.anchors.last().unwrap().p)).collect(); + assert_eq!(spans.len(), 3, "{spans:?}"); + assert!(near(spans[0].0, 0.0, 100.0) && near(spans[0].1, 300.0, 100.0), "{spans:?}"); + assert!(near(spans[1].0, 50.0, 120.0) && near(spans[1].1, 100.0, 120.0), "{spans:?}"); + assert!(near(spans[2].0, 200.0, 120.0) && near(spans[2].1, 250.0, 120.0), "{spans:?}"); +} + +#[test] +fn a_clipping_path_is_not_trimmed_and_does_not_count_as_crossing() { + let mut s = session(); + let (h, v1, _) = across_and_uprights(&mut s); + s.edit("clip", |d, _| { + if let NodeKind::Path { clipping, .. } = &mut d.node_mut(v1).unwrap().kind { + *clipping = true; + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0)); + assert_eq!(path(&s, v1).anchor_count(), 2); + // The line across is crossed by the second upright only: its stretch from the start goes. + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).0, 200.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0)); + // Type over the rest of it takes no part either: nothing crosses the rest, and it goes whole. + let t = NodeId(s.execute("text.create", &json!({"x": 220, "y": 115, "text": "HHHH", "size": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.execute(TRIM, &json!({"rect": [215, 70, 70, 50]})).unwrap(), json!({"ids": [], "removed": 1})); + assert!(!exists(&s, h) && exists(&s, t)); +} + +#[test] +fn the_members_of_a_compound_clipping_path_are_not_trimmed_and_do_not_count_as_crossing() { + let mut s = session(); + let (h, v1, _) = across_and_uprights(&mut s); + let extra = line(&mut s, &[(250.0, 250.0), (260.0, 260.0)], false); + s.execute("select.set", &json!({"ids": [v1.0, extra.0]})).unwrap(); + let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [h.0, compound.0]})).unwrap(); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let n = steps(&s); + // A click on the clipping path's member changes nothing, and the rectangle over it neither. + assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0)); + assert_eq!(s.execute(TRIM, &json!({"rect": [245, 245, 20, 20]})).unwrap()["removed"], json!(0)); + assert_eq!((steps(&s), path(&s, v1).anchor_count(), path(&s, extra).anchor_count()), (n, 2, 2)); + // The clipped line across is crossed by the second upright only: its stretch from the start goes. + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).0, 200.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0)); +} + +#[test] +fn in_isolation_mode_only_the_isolated_objects_paths_are_trimmed() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + s.execute("select.set", &json!({"ids": [v1.0, v2.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + // The line across is outside the group: a click or a rectangle on it leaves it whole. + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(0)); + assert_eq!(s.execute(TRIM, &json!({"rect": [140, 90, 20, 20]})).unwrap()["removed"], json!(0)); + assert!(near(ends(&s, h).0, 0.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0)); + // It still crosses the uprights: a rectangle over the tops of both removes them down to it. + assert_eq!(s.execute(TRIM, &json!({"rect": [90, 40, 120, 20]})).unwrap()["removed"], json!(2)); + assert!(near(ends(&s, v1).0, 100.0, 100.0) && near(ends(&s, v2).0, 200.0, 100.0)); + assert!(near(ends(&s, h).0, 0.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0)); + // An isolated path is trimmed itself. + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("object.isolate", &json!({"id": h.0})).unwrap(); + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).1, 100.0, 100.0)); +} + +#[test] +fn a_rectangle_over_too_many_paths_to_check_is_refused() { + let mut s = session(); + // 7,072 short lines apart from each other: each of them against every other is past the limit. + s.edit("many", |d, _| { + let layer = d.layers[0].id; + for i in 0..7072 { + let (x, y) = ((i % 100) as f64 * 3.0, (i / 100) as f64 * 3.0); + let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(x, y), Point::new(x + 1.0, y)], false); + let id = d.alloc_id(); + let top = d.node(layer).unwrap().children().unwrap().len(); + d.insert(Some(layer), top, vectorcraft_doc::Node::path(id, PathData::single(sp), Default::default()))?; + } + Ok(()) + }) + .unwrap(); + let n = steps(&s); + let err = s.execute(TRIM, &json!({"rect": [-10, -10, 400, 300]})).unwrap_err().to_string(); + assert!(err.contains("too many paths"), "{err}"); + assert_eq!((steps(&s), stack(&s).len()), (n, 7072)); + // A rectangle over a few of them is checked. + assert_eq!(s.execute(TRIM, &json!({"rect": [-1, -1, 5, 2]})).unwrap()["removed"], json!(2)); +} + +#[test] +fn a_path_too_large_to_search_is_refused_only_when_touched() { + let mut s = session(); + let (h, ..) = across_and_uprights(&mut s); + // A border of 10,001 segments round all of them, more than a path to trim may have. + let mut border: Vec<(f64, f64)> = (0..=10_001).map(|i| (-500.0 + i as f64 * 0.2, -500.0)).collect(); + border.push((-500.0, 1500.0)); + let b = line(&mut s, &border, true); + // A rectangle and a line over the middle of the line across reach inside the border's box only. + assert_eq!(s.execute(TRIM, &json!({"rect": [140, 90, 20, 20]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).1, 100.0, 100.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute(TRIM, &json!({"points": [[150, 80], [150, 120]]})).unwrap()["removed"], json!(1)); + // A rectangle on the border is refused, and changes nothing. + let n = steps(&s); + let err = s.execute(TRIM, &json!({"rect": [-510, 0, 20, 20]})).unwrap_err().to_string(); + assert!(err.contains("too many or too tangled"), "{err}"); + assert_eq!((steps(&s), path(&s, b).anchor_count()), (n, 10_003)); +} + +#[test] +fn a_path_nothing_crosses_goes_whole_open_or_closed() { + let mut s = session(); + let open = line(&mut s, &[(0.0, 10.0), (50.0, 10.0), (50.0, 40.0)], false); + let closed = line(&mut s, &[(100.0, 10.0), (150.0, 10.0), (150.0, 40.0)], true); + let n = steps(&s); + assert_eq!(s.execute(TRIM, &json!({"at": [25, 10]})).unwrap(), json!({"ids": [], "removed": 1})); + assert_eq!(s.execute(TRIM, &json!({"at": [125, 10]})).unwrap(), json!({"ids": [], "removed": 1})); + assert!(!exists(&s, open) && !exists(&s, closed)); + assert_eq!(steps(&s), n + 2); +} + +#[test] +fn a_path_that_ends_on_another_marks_a_place_and_paths_along_each_other_do_not() { + let mut s = session(); + let h = line(&mut s, &[(0.0, 100.0), (300.0, 100.0)], false); + let stub = line(&mut s, &[(150.0, 100.0), (150.0, 160.0)], false); + // The stub ends on the line: the line's stretch stops there, and the stub is one stretch. + s.execute(TRIM, &json!({"at": [50, 100]})).unwrap(); + assert!(near(ends(&s, h).0, 150.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0)); + assert_eq!(s.execute(TRIM, &json!({"at": [150, 130]})).unwrap()["removed"], json!(1)); + assert!(!exists(&s, stub)); + // A second line along the first doesn't mark it: the lower one goes whole. + let along = line(&mut s, &[(200.0, 100.0), (260.0, 100.0)], false); + s.execute(TRIM, &json!({"rect": [270, 90, 10, 20]})).unwrap(); + assert!(!exists(&s, h) && exists(&s, along)); +} + +#[test] +fn a_self_crossing_path_loses_its_loop() { + let mut s = session(); + // A bow: its first and third segments cross at (50, 50). + let bow = line(&mut s, &[(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)], false); + let r = s.execute(TRIM, &json!({"at": [100, 50]})).unwrap(); + assert_eq!(r["removed"], json!(1)); + let made = ids(&r); + assert_eq!(made.len(), 2); + assert_eq!(made[0], bow); + assert!(near(ends(&s, made[0]).1, 50.0, 50.0) && near(ends(&s, made[1]).0, 50.0, 50.0)); +} + +#[test] +fn compound_path_members_are_trimmed_and_an_emptied_compound_path_goes() { + let mut s = session(); + let a = line(&mut s, &[(0.0, 100.0), (300.0, 100.0)], false); + let b = line(&mut s, &[(100.0, 50.0), (100.0, 150.0)], false); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let members = |s: &Session| s.doc().unwrap().doc.node(compound).map(|n| n.children().unwrap().len()); + assert_eq!(members(&s), Some(2)); + // The members cross each other: a middle stretch splits one member in the compound path. + assert_eq!(s.execute(TRIM, &json!({"at": [100, 75]})).unwrap()["removed"], json!(1)); + assert_eq!(members(&s), Some(2)); + assert_eq!(s.execute(TRIM, &json!({"at": [100, 125]})).unwrap()["removed"], json!(1)); + assert_eq!(members(&s), Some(1)); + assert_eq!(s.execute(TRIM, &json!({"at": [200, 100]})).unwrap()["removed"], json!(1)); + assert_eq!(members(&s), None, "the emptied compound path is removed"); + assert!(stack(&s).is_empty()); +} + +#[test] +fn the_selection_stays_as_it_was_and_the_pieces_of_a_selected_path_are_selected() { + let mut s = session(); + let (_, v1, _) = across_and_uprights(&mut s); + s.execute("select.set", &json!({"ids": [v1.0]})).unwrap(); + s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, [v1], "the trimmed path was not selected"); + let mut s = session(); + let (h, ..) = across_and_uprights(&mut s); + s.execute("select.set", &json!({"ids": [h.0]})).unwrap(); + let made = ids(&s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()); + assert_eq!(made.len(), 2); + let sel = &s.doc().unwrap().selection; + assert!(made.iter().all(|id| sel.contains(*id)), "{:?}", sel.objects); + // The second piece now ends on an upright: nothing crosses it, and it goes whole. + s.execute("select.set", &json!({"ids": [made[1].0]})).unwrap(); + assert_eq!(s.execute(TRIM, &json!({"at": [250, 100]})).unwrap(), json!({"ids": [], "removed": 1})); + assert!(s.doc().unwrap().selection.is_empty(), "a removed path leaves the selection"); +} + +#[test] +fn many_paths_far_from_the_trimmed_one_cost_no_crossing_search() { + let mut s = session(); + let (h, ..) = across_and_uprights(&mut s); + // 3,000 short lines far below, each crossing its neighbor. + s.edit("many", |d, _| { + let layer = d.layers[0].id; + for i in 0..3000 { + let x = (i % 100) as f64 * 3.0; + let y = 1000.0 + (i / 100) as f64 * 3.0; + let sp = vectorcraft_geom::SubPath::polyline( + &[Point::new(x, y), Point::new(x + 5.0, y + 5.0), Point::new(x + 5.0, y), Point::new(x, y + 5.0)], + false, + ); + let id = d.alloc_id(); + let top = d.node(layer).unwrap().children().unwrap().len(); + d.insert(Some(layer), top, vectorcraft_doc::Node::path(id, PathData::single(sp), Default::default()))?; + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).1, 100.0, 100.0)); + // A rectangle over all of them takes every stretch it touches. + let r = s.execute(TRIM, &json!({"rect": [-10, 990, 400, 200]})).unwrap(); + assert!(r["removed"].as_u64().unwrap() >= 3000, "{}", r["removed"]); + assert_eq!(stack(&s).len(), 4, "the line across in two pieces and the uprights"); +} + +#[test] +fn the_tool_trims_on_release_as_one_step_and_escape_drops_a_drag() { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let mut s = session(); + let (h, v1, _) = across_and_uprights(&mut s); + let h2 = line(&mut s, &[(0.0, 120.0), (300.0, 120.0)], false); + let view = crate::ViewInfo { smart_guides: false, ..crate::ViewInfo::default() }; + s.select_tool("trimAtCrossings", view).unwrap(); + assert_eq!(s.tool_id(), "trimAtCrossings"); + let n = steps(&s); + // A drag over the middle of both lines across: nothing changes until the release. + s.pointer(&PointerEvent::new(PointerKind::Down, 140.0, 90.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 130.0), view).unwrap(); + assert!(near(ends(&s, h).1, 300.0, 100.0)); + s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 130.0), view).unwrap(); + assert!(near(ends(&s, h).1, 100.0, 100.0) && near(ends(&s, h2).1, 100.0, 120.0)); + assert_eq!(steps(&s), n + 1, "one undo step for the drag"); + // Escape during a drag: the release removes nothing. + s.pointer(&PointerEvent::new(PointerKind::Down, 90.0, 40.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 110.0, 60.0), view).unwrap(); + s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 60.0), view).unwrap(); + assert_eq!(steps(&s), n + 1); + assert!(near(ends(&s, v1).0, 100.0, 50.0)); + // A click on the upright's top stretch removes it. + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 70.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 70.0), view).unwrap(); + assert!(near(ends(&s, v1).0, 100.0, 100.0)); + assert_eq!(steps(&s), n + 2); +} + +#[test] +fn a_line_too_long_to_check_against_the_paths_is_refused() { + let mut s = session(); + // A zigzag of 1,000 segments, and a line of 100,000 points inside its box that crosses none. + let zigzag: Vec<(f64, f64)> = (0..=1000).map(|i| (i as f64 * 0.3, (i % 2) as f64 * 200.0)).collect(); + let z = line(&mut s, &zigzag, false); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + let points: Vec = (0..100_000).map(|i| json!([150.0 + (i % 2) as f64 * 0.01, 150.0])).collect(); + let err = s.execute(TRIM, &json!({ "points": points })).unwrap_err().to_string(); + assert!(err.contains("too long"), "{err}"); + assert_eq!(steps(&s), n); + assert!(*s.doc().unwrap().doc == *before); + // A short line there is checked, and removes nothing. + assert_eq!(s.execute(TRIM, &json!({"points": [[150.0, 150.0], [150.01, 150.0]]})).unwrap()["removed"], json!(0)); + assert!(exists(&s, z)); +} + +#[test] +fn bad_params_are_refused_and_change_nothing() { + let mut s = session(); + across_and_uprights(&mut s); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + for bad in [ + json!({}), + json!({"at": null}), + json!({"at": [150]}), + json!({"at": [150, "y"]}), + json!({"at": [150, 1e308]}), + json!({"at": [150, 100], "tolerance": -1}), + json!({"at": [150, 100], "tolerance": 101}), + json!({"at": [150, 100], "tolerance": "3"}), + json!({"at": [150, 100], "rect": [140, 90, 20, 40]}), + json!({"rect": [140, 90, 20]}), + json!({"rect": "all"}), + json!({"rect": [0, 0, 1e9, 5]}), + json!({"points": [[150, 80]]}), + json!({"points": [[150, 80], [150]]}), + json!({"points": "x"}), + json!({"points": [[150, 80], [150, 130]], "at": [1, 2]}), + ] { + assert!(s.execute(TRIM, &bad).is_err(), "{bad}"); + } + assert_eq!(steps(&s), n); + assert!(*s.doc().unwrap().doc == *before); +} diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 79a203c421..a1cc756f40 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, true) ), @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, false) ), @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, clear_appearance ), @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, reduce_basic ), @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, set_item ), @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, remove_item ), @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, duplicate_item ), @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -129,7 +129,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}", has_doc, copy_from ), @@ -147,7 +147,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}", has_doc, show_all_hidden ), @@ -165,7 +165,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}", has_doc, transfer ), @@ -508,8 +508,9 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke /// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters). -/// Nothing targeted is not an edit, and when `f` leaves every target as it was, the document and -/// its undo history stay as they are. +/// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on +/// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and +/// when `f` leaves every target as it was, the document and its undo history stay as they are. pub(crate) fn edit_items( s: &mut Session, ids: &[NodeId], @@ -519,12 +520,13 @@ pub(crate) fn edit_items( fill: bool, f: impl FnMut(&mut Node, Option) -> Result<()>, ) -> Result<()> { - edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(false)) + edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) } -/// [`edit_items`], then `after` on the ids `f` ran on, inside the same undo step. `after` returns -/// whether it changed the document; when it did not and `f` left every target as it was, the -/// document and its undo history stay as they are. +/// [`edit_items`], then `after` on the document as it was before the edit, the edited document and +/// the ids `f` ran on, inside the same undo step. `after` returns whether it changed the document; +/// when it did not and `f` left every target as it was, the document and its undo history stay as +/// they are. #[allow(clippy::too_many_arguments)] pub(crate) fn edit_items_then( s: &mut Session, @@ -534,7 +536,7 @@ pub(crate) fn edit_items_then( label: &str, fill: bool, mut f: impl FnMut(&mut Node, Option) -> Result<()>, - mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result, + mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result, ) -> Result<()> { if ids.is_empty() { return Ok(()); @@ -543,15 +545,21 @@ pub(crate) fn edit_items_then( let per = item.per_target(s, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); + // The objects whose rows `f` edited: on type, no row means its characters. + let mut rows = Vec::new(); for (id, item) in ids.iter().zip(&per) { let Some(item) = item else { continue }; let Some(n) = d.node_mut(*id) else { continue }; let index = item.resolve(&n.appearance, fill, cmd)?; f(n, index)?; edited.push(*id); + if index.is_some() { + rows.push(*id); + } } - let more = after(d, &edited)?; - Ok(count_changed(d, &before, &edited) + usize::from(more)) + let more = after(&before, d, &edited)?; + let labels = super::graph::capture_label_rows(d, &rows); + Ok(count_changed(d, &before, &edited) + usize::from(more || labels)) })?; Ok(()) } @@ -688,6 +696,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { let mut rows = top_paint_changed(&before, d, &ids); rows.extend(painted_rows(d, added)); super::graph::capture_series_top_paints(d, &rows); + super::graph::capture_label_rows(d, &ids); Ok(()) })?; // A fill added above the active row becomes the active row; otherwise the rows above the new @@ -710,7 +719,8 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); let object = super::graph::capture_series_object_opacity(d, &ids); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object || labels)) })?; s.active_appearance_item = None; ok() @@ -791,6 +801,52 @@ pub(super) fn capture_series_writes( kept || painted || stored } +/// Store on the graph what an edit of `ids` (from `before` to `d`) changed on the series marks among them: the paint, +/// weight, opacity or blend mode of the topmost visible fill and stroke, and the object opacity and blend mode. Rows +/// and objects the edit left as they were keep what the graph stores. When the edit changes several marks of one series, +/// the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint changed. +pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut vectorcraft_doc::Document, ids: &[NodeId]) -> bool { + let marks = series_children_in_paint_order(d, ids); + !marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false) +} + +/// Each of `ids` directly in a series group, from the back to the front: a later mark's capture replaces an earlier +/// one's, so the frontmost one sets what the graph stores. +pub(crate) fn series_children_in_paint_order(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { + // Only objects directly in a series group can be marks: other edits skip the search of the document for graphs. + let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some()); + let mut marks: Vec = ids.iter().copied().filter(in_series).collect(); + marks.sort_by_cached_key(|id| d.index_path(*id)); + marks +} + +/// Store on the graph the paint that an edit of `ids` (from `before` to `d`) leaves on the topmost visible fill and stroke +/// of the series marks among them: a paint or stroke weight that changed, and the paint of each row the edit wrote +/// (`written`: an object with the index of the row) when that row is the topmost visible one of its kind and has paint. +/// A row written under that one, and a hidden one, keep what the graph stores. When the edit reaches several marks of one +/// series, the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint +/// changed. +pub(crate) fn capture_series_paint_writes( + before: &vectorcraft_doc::Document, + d: &mut vectorcraft_doc::Document, + ids: &[NodeId], + written: &[(NodeId, usize)], +) -> bool { + // Only objects directly in a series group can be marks: other edits skip the comparison of paints and the search of + // the document for graphs. + let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some()); + let mut marks: Vec = ids.iter().copied().filter(in_series).collect(); + // A later mark's capture replaces an earlier one's: take them in paint order, each with all of its rows. + marks.sort_by_cached_key(|id| d.index_path(*id)); + let mut rows = Vec::new(); + for id in &marks { + rows.extend(top_paint_changed(before, d, &[*id])); + let shown = written.iter().filter(|(w, _)| w == id).filter_map(|(_, at)| Some((*id, top_row_kind(&d.node(*id)?.appearance, *at)?))); + rows.extend(painted_rows(d, shown.collect::>())); + } + super::graph::capture_series_top_paints(d, &rows) +} + /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. fn top_row_kind(ap: &Appearance, at: usize) -> Option { let fill = ap.items.get(at)?.is_fill(); @@ -869,8 +925,10 @@ fn transfer(s: &mut Session, p: &Value) -> Result { rows.extend(painted_rows(d, both_kinds(&targets))); let painted = super::graph::capture_series_top_paints(d, &rows); let object = super::graph::capture_series_object_opacity(d, &changed); + // The targets last: on two labels of one kind, the target's rows are the ones the kind keeps. + let labels = super::graph::capture_label_rows(d, &changed); let all: Vec = from.iter().chain(&targets).chain(&group).copied().collect(); - Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object)) + Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object || labels)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -926,7 +984,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; s.active_appearance_item = None; ok() @@ -1001,7 +1060,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &ids); rows.extend(painted_rows(d, written)); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; if let Some(pa) = &paint { s.remember_paint(pa); @@ -1047,7 +1107,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; // The rows removed from the active row's object, which need not come first in `ids`. let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); @@ -1131,6 +1192,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &ids); rows.extend(painted_rows(d, added)); super::graph::capture_series_top_paints(d, &rows); + super::graph::capture_label_rows(d, &ids); Ok(()) })?; // Rows at or above each copy move up by one (by the block, for copies placed at `to`). @@ -1170,6 +1232,7 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result { } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &shown)); super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &shown)); + super::graph::capture_label_rows(d, &shown); Ok(shown) })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) @@ -1215,7 +1278,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; // The moved row stays the active one; the rows it passed shift by one. s.remap_appearance_item(&ids, |a| { @@ -1260,7 +1324,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result Result { slot.get_or_insert(n.appearance.contents_at()); } slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers, type and images have a Contents (Characters) row"))?; - Ok(count_changed(d, &before, ids)) + // A graph label's kind keeps where its rows paint relative to the characters. + let labels = super::graph::capture_label_rows(d, ids); + Ok(count_changed(d, &before, ids) + usize::from(labels)) })?; Ok(json!({ "contents": slot })) } @@ -1447,9 +1514,46 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() { return Ok(finish(s, &[])); } + // What a graph keeps for the kind of a label whose whole text took the pick: the attributes the pick gave its + // characters, with a picked gradient fitted to each label, and the picked paragraph attributes. + let source_rows = picked.placed(None); + let kept_text = |id: NodeId, st: &mut CharStyle| { + Picked::run(st, &source_rows, &attrs, characters.get(&id)); + if attrs.appearance.fill.color { + st.fill = super::gradient::unplaced(&st.fill); + } + if attrs.appearance.stroke.color { + st.stroke = super::gradient::unplaced(&st.stroke); + } + }; + let kept_para = picked.text.as_ref().filter(|_| attrs.paragraph).map(|(_, para)| para); + // Only a pick that applies character attributes or the paints, overprint and stroke options characters take changes + // what a kind keeps for its characters. + let (f, k) = (attrs.appearance.fill, attrs.appearance.stroke); + let to_characters = (attrs.character && picked.text.is_some()) + || f.color + || f.overprint + || k.color + || k.overprint + || k.weight + || k.cap + || k.join + || k.miter + || k.dash; + let kept_text: Option = to_characters.then_some(&kept_text); if !ranges.is_empty() { let ids: Vec = ranges.iter().map(|(f, _)| *f).collect(); let before = s.doc()?.doc.clone(); + // The labels whose whole text is highlighted. + let whole: Vec = ranges + .iter() + .filter(|(id, range)| match (range, before.node(*id).map(|n| &n.kind)) { + (None, _) => true, + (Some((start, end)), Some(NodeKind::Text(t))) => *start == 0 && *end >= vectorcraft_text::edit::runs_len(&t.runs), + _ => false, + }) + .map(|(id, _)| *id) + .collect(); super::overprint::edit_counted(s, "Eyedropper", |d| { for &(id, range) in &ranges { let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; @@ -1462,7 +1566,8 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { super::typecmd::refresh_bounds(t); } check_text_bounds(d)?; - Ok(count_changed(d, &before, &ids)) + let labels = super::graph::capture_label_text(d, &whole, kept_text, kept_para)?; + Ok(count_changed(d, &before, &ids) + usize::from(labels)) })?; return Ok(finish(s, &ids)); } @@ -1517,7 +1622,17 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { // A pick of the object's transparency writes it on every target. let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) }; let object = super::graph::capture_series_object_opacity(d, &objects); - Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object)) + // Graph labels keep their new rows, characters and paragraphs per kind of label. + // On type, only `append` adds rows and a transparency pick changes the object's opacity and blend mode; the other + // attributes go to the characters. + let rows = if append { + super::graph::capture_label_rows(d, &targets) + } else { + attrs.appearance.transparency && super::graph::capture_label_object_opacity(d, &targets) + }; + // `append` adds rows and leaves the characters as they were. + let text = !append && super::graph::capture_label_text(d, &targets, kept_text, kept_para)?; + Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object || rows || text)) })?; Ok(finish(s, &targets)) } @@ -1550,6 +1665,7 @@ fn copied_character_styles<'a>(source: &Node, targets: impl Iterator bool { (from.baseline_shift, to.baseline_shift), (from.tcy_up_down, to.tcy_up_down), (from.tcy_left_right, to.tcy_left_right), + (from.warichu_gap, to.warichu_gap), (from.h_scale, to.h_scale), ] .iter() diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index bbc56dbc15..6de2fbf9a2 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -3,7 +3,11 @@ //! shared colour visitor [`Recolor`]: fills and strokes (solid colours, gradient stops and text //! runs), gradient meshes, embedded images and pattern fills. Colours linked to a global swatch are //! unlinked when they change; Adjust Color Balance's Global mode shifts their tints instead. +//! +//! Convert to Indexed Color uses the same visitor for embedded images only, and stores each one as +//! a palette PNG. +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -15,7 +19,9 @@ use vectorcraft_doc::live::lerp_color; use vectorcraft_doc::pattern::PatternDef; pub(crate) use vectorcraft_doc::recolor::map_paint; use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node}; -use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Document, ImageBlob, MAX_RASTER_ALLOC, Node, NodeId, NodeKind}; +use vectorcraft_render::encode::png::{PngOptions, encode_indexed}; +use vectorcraft_render::encode::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_to}; use super::edit::selected_roots; use super::{ @@ -30,7 +36,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", has_selection, |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), @@ -66,6 +72,15 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "edit.colors.toIndexed", + "Convert to Indexed Color…", + ["Edit", "Edit Colors"], + None, + "{reduction?: \"selective\" (default)|\"perceptual\"|\"adaptive\"|\"web\"|\"gray\"|\"blackWhite\" (chosen as for a PNG-8 export)|\"uniform\" (evenly spaced levels of red, green and blue: the most levels, 2 to 6, whose cube fits in `colors` with one entry left for transparency)|\"document\" (the colors of the document's swatches, at most 255), colors?: 2..256 (default 256; the most palette entries, the transparent one included; at least 9 for uniform; refused for blackWhite and document), dither?: \"diffusion\" (default)|\"pattern\"|\"noise\"|\"none\", ditherAmount?: 0..100 (default 100), ids?} store each embedded image in the selection (in groups too) as an indexed-color PNG: a palette of at most 256 RGB colors, each pixel the index of one; pixels under half opacity become one transparent entry and the others opaque, keeping their color; linked images, colorized images and the images of color glyphs in outlined type keep their pixels; each image changed becomes a new image, one undo step for all; an image that is an indexed PNG already and would show the same pixels is left as it is, and no undo step is recorded when no image changes; an image of more than 134,217,728 pixels (33,554,432 in the web app) is refused with an error and nothing changes; reduction and dither ids are read as for a PNG-8 export, ignoring case → {changed: images changed, notChanged: the locked and hidden objects Select All left out that hold an image that is not an indexed PNG yet, warning?} (as edit.colors.invert)", + has_selection, + to_indexed + ), cmd!( "edit.colors.saturate", "Saturate…", @@ -89,7 +104,7 @@ pub fn specs() -> Vec { "Blend Front to Back", ["Edit", "Edit Colors"], None, - "{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill → {changed}", + "{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md) → {changed}", has_selection, |s, _| blend(s, BlendOrder::Stack) ), @@ -149,6 +164,13 @@ fn gray_level(c: Color) -> f32 { /// gradient stop): `(colour, link, tint)`, true when it changed them. pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option, &mut f32) -> bool + 'a; +/// What a pass that remakes whole images makes of one: its new copy, `None` when it stays as it is. +type Remake<'a> = dyn Fn(&ImageBlob) -> Option + 'a; + +/// The objects a pass recolors, and the locked and hidden objects the selection left out, each +/// with whether it is hidden ([`pass_targets`]). +type Targets = (Vec, Vec<(NodeId, bool)>); + /// The identity colour map (a pass that only maps linked colours). fn same(c: Color) -> Color { c @@ -191,6 +213,8 @@ pub(crate) struct Recolor<'a> { scope: Scope, /// Recolored images are stored with one gray channel ([`ImageBlob::map_gray`]). gray_images: bool, + /// Makes each embedded image's new copy in place of `f` (`None`: it stays as it is). + remake: Option<&'a Remake<'a>>, /// Image key → the key of its recoloured copy (`None`: unchanged). images: HashMap>, /// Pattern name → the name of its recoloured copy (`None`: unchanged, or being recoloured). @@ -207,6 +231,7 @@ impl<'a> Recolor<'a> { links: None, scope, gray_images: false, + remake: None, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], @@ -229,7 +254,7 @@ impl<'a> Recolor<'a> { /// A pass that maps colors as this one does, with nothing recolored yet. fn again(&self) -> Self { - Self { links: self.links, gray_images: self.gray_images, ..Self::new(self.scope, self.f) } + Self { links: self.links, gray_images: self.gray_images, remake: self.remake, ..Self::new(self.scope, self.f) } } /// The entries of `objects` (ids, each with a flag) whose object this pass would change, other @@ -328,7 +353,9 @@ impl<'a> Recolor<'a> { } let f = self.f; let blob = d.images.get(key)?; - let new = if self.gray_images { + let new = if let Some(remake) = self.remake { + remake(blob) + } else if self.gray_images { blob.map_gray(|[r, g, b]| f(Color::rgb8(r, g, b)).to_rgba8(1.0)[0]) } else { blob.map_rgb(|[r, g, b]| { @@ -446,8 +473,11 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let (n, not_changed) = s.edit(label, |d, _| { let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden)); - let n = pass.run(d, &ids); - super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; + let n = keeping_series_paint(d, &ids, |d| { + let n = pass.run(d, &ids); + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; + Ok(n) + })?; Ok((n, not_changed)) })?; let mut out = json!({ "changed": n }); @@ -455,6 +485,19 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - Ok(out) } +/// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed +/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn +/// again. +pub(crate) fn keeping_series_paint(d: &mut Document, roots: &[NodeId], recolor: impl FnOnce(&mut Document) -> Result) -> Result { + let marks = super::graph::series_marks_in(d, roots); + let before = (!marks.is_empty()).then(|| d.clone()); + let n = recolor(d)?; + if let Some(before) = before.filter(|_| n > 0) { + super::appearance::capture_series_changes(&before, d, &marks); + } + Ok(n) +} + /// The objects a recolor of `roots` leaves out of the art the user meant to recolor, each with /// whether it is hidden (otherwise it is locked). When `roots` are exactly the objects Select All /// takes (in isolation mode, those of the isolated group), these are the objects beside them that @@ -534,6 +577,187 @@ impl NotChanged { } } +/// The objects a pass recolors (the `ids` param's, default: the selection's) and the locked and +/// hidden objects the selection left out ([`left_out`]), each with whether it is hidden. +fn pass_targets(s: &Session, p: &Value, cmd: &str) -> Result { + Ok(match ids_param(p, "ids", cmd)? { + Some(v) => (v, vec![]), + None => { + let roots = selected_roots(s)?; + let st = s.doc()?; + let left = left_out(&st.doc, st.isolation, &roots); + (roots, left) + } + }) +} + +/// Edit › Edit Colors › Convert to Indexed Color: the embedded images of the objects stored as +/// indexed-color PNGs ([`Indexing`]). The pass runs on a copy of the document, so no undo step is +/// recorded when no image changes. +fn to_indexed(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.colors.toIndexed"; + let indexing = Indexing::of(s, p)?; + // A typing session ends here as `s.edit` would end it, before the copy is taken, so that the + // copy holds what was typed. + if !s.previewing && super::typing_in_progress(s) { + super::settle_interaction(s)?; + } + let remake = |b: &ImageBlob| indexing.convert(b); + let images = Scope { fill: false, stroke: false, images: true, patterns: false }; + let pass = Recolor { remake: Some(&remake), ..Recolor::new(images, &same) }; + // A left-out image counts as one the pass would change when it is an image that is not an + // indexed PNG yet; it is not converted to find out. The empty PNG only gives it a new key. + let would = |b: &ImageBlob| (!b.indexed_png() && b.pixel_size().is_some()).then(|| ImageBlob::png(vec![])); + let (ids, left) = pass_targets(s, p, C)?; + let st = s.doc()?; + let colorized_ok = super::paint::check_colorized(&st.doc).is_ok(); + let not_changed = NotChanged::of( + Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(&st.doc, &left).into_iter().map(|(_, hidden)| hidden), + ); + let mut doc = (*st.doc).clone(); + let n = pass.run(&mut doc, &ids); + if let Some((w, h)) = indexing.too_large.get() { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS))); + } + if n > 0 { + s.edit("Convert to Indexed Color", |d, _| { + *d = doc; + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e)) + })?; + } + let mut out = json!({ "changed": n }); + not_changed.report(&mut out); + Ok(out) +} + +/// How Convert to Indexed Color reduces an image's colors: the palette options of a PNG-8 +/// export, with transparency kept and no matte, and the fixed palette of the uniform and document +/// reductions. +struct Indexing { + options: PaletteOptions, + fixed: Option>, + /// The size of an image [`Self::convert`] left as it is because it has more than + /// [`Self::MAX_PIXELS`] pixels. + too_large: Cell>, +} + +impl Indexing { + /// The most pixels an image may have. Converting one holds at most about 8 bytes a pixel at + /// once: decoding (the decoded samples and the RGBA), quantizing (the RGBA, the colors and the + /// indices) and writing the PNG after the RGBA is freed (the indices, the packed and filtered + /// rows and the compressed data). 16 bytes a pixel keeps that within [`MAX_RASTER_ALLOC`] twice + /// over. + const MAX_PIXELS: u64 = MAX_RASTER_ALLOC / 16; + + /// The reduction `p` asks for (see `edit.colors.toIndexed`); a value out of range or of the + /// wrong type is an error. Reduction and dither ids are read as a PNG-8 export reads them. + fn of(s: &Session, p: &Value) -> Result { + const C: &str = "edit.colors.toIndexed"; + let reduction = text_param(p, "reduction", C)?.unwrap_or("selective"); + let colors = match p.get("colors") { + None | Some(Value::Null) => None, + Some(v) => { + Some(v.as_u64().filter(|n| (2..=256).contains(n)).ok_or_else(|| bad(C, "`colors` must be a whole number from 2 to 256"))? as u16) + } + }; + let dither = match text_param(p, "dither", C)? { + None => Dither::Diffusion, + Some(d) => Dither::from_id(d).ok_or_else(|| bad(C, format!("unknown dither `{d}` (diffusion, pattern, noise or none)")))?, + }; + let dither_amount = match p.get("ditherAmount") { + None | Some(Value::Null) => 100, + Some(v) => { + v.as_f64().filter(|x| (0.0..=100.0).contains(x)).ok_or_else(|| bad(C, "`ditherAmount` must be a number from 0 to 100"))?.round() as u8 + } + }; + let options = + |r: Reduction| PaletteOptions { colors: colors.unwrap_or(256), reduction: r, dither, dither_amount, transparency: true, matte: None }; + let refuse_colors = |name: &str| match colors { + Some(_) => Err(bad(C, format!("`colors` does not apply to the {name} reduction"))), + None => Ok(()), + }; + let fixed = |palette: Vec<[u8; 3]>| Self { options: options(Reduction::Adaptive), fixed: Some(palette), too_large: Cell::new(None) }; + Ok(match reduction.to_ascii_lowercase().as_str() { + "uniform" => { + let n = colors.unwrap_or(256); + // One entry is kept for transparent pixels. + let levels = + (2..=6u16).rev().find(|l| l.pow(3) < n).ok_or_else(|| bad(C, "`colors` must be at least 9 for the uniform reduction"))?; + let level = |i: u16| (u32::from(i) * 255 / u32::from(levels - 1)) as u8; + let mut palette = Vec::with_capacity(usize::from(levels.pow(3))); + for r in 0..levels { + for g in 0..levels { + for b in 0..levels { + palette.push([level(r), level(g), level(b)]); + } + } + } + fixed(palette) + } + "document" => { + refuse_colors("document")?; + let mut palette: Vec<[u8; 3]> = vec![]; + for c in s.doc()?.doc.swatches_iter().filter_map(|sw| sw.paint.color()) { + let [r, g, b, _] = c.to_rgba8(1.0); + if !palette.contains(&[r, g, b]) { + palette.push([r, g, b]); + } + } + if palette.is_empty() { + return Err(bad(C, "the document has no color swatches")); + } + if palette.len() > 255 { + return Err(bad(C, format!("the document's swatches hold {} colors; the document reduction takes at most 255", palette.len()))); + } + fixed(palette) + } + id => { + let r = Reduction::from_id(id).ok_or_else(|| { + bad(C, format!("unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document)")) + })?; + if r == Reduction::BlackWhite { + refuse_colors("blackWhite")?; + } + Self { options: options(r), fixed: None, too_large: Cell::new(None) } + } + }) + } + + /// Image `b` as an indexed-color PNG; `None` when it can't be decoded, when it has more than + /// [`Self::MAX_PIXELS`] pixels (noted in `too_large`), or when it is an indexed PNG already and + /// every pixel would show as it does. + fn convert(&self, b: &ImageBlob) -> Option { + let (w, h) = b.pixel_size()?; + if u64::from(w) * u64::from(h) > Self::MAX_PIXELS { + self.too_large.set(Some((w, h))); + return None; + } + let (w, h, rgba) = b.rgba()?; + let ix = match &self.fixed { + Some(palette) => quantize_to(&rgba, w, h, &self.options, palette), + None => quantize(&rgba, w, h, &self.options), + }; + if b.indexed_png() && shows_same(&ix, &rgba) { + return None; + } + // The PNG writer's buffers take the place of the pixels. + drop(rgba); + Some(ImageBlob::png(encode_indexed(&ix, &PngOptions::default()).ok()?)) + } +} + +/// Whether palette image `ix` shows every pixel of straight RGBA `rgba` as it is: transparent +/// where they are transparent, and the same color where they are opaque. +fn shows_same(ix: &Indexed, rgba: &[u8]) -> bool { + let px = rgba.as_chunks::<4>().0; + px.len() == ix.indices.len() + && px.iter().zip(&ix.indices).all(|(p, &i)| match (p[3], ix.transparent == Some(i)) { + (0, clear) => clear, + (255, false) => ix.palette.get(usize::from(i)).is_some_and(|c| c[..] == p[..3]), + _ => false, + }) +} + fn saturate(s: &mut Session, p: &Value) -> Result { let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32; recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| { @@ -679,7 +903,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { }; let fills = Scope { fill: true, stroke: false, images: false, patterns: false }; let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); - let changed = s.edit(label, |d, _| { + let run = |d: &mut Document| -> Result { let mut changed = 0; for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) { let c = lerp_color(&first, &last, i as f32 / (n - 1) as f32).in_model(model); @@ -698,7 +922,8 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { } super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; Ok(changed) - })?; + }; + let changed = s.edit(label, |d, _| keeping_series_paint(d, &ids, run))?; Ok(json!({ "changed": changed })) } diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index c94de5d3b2..66295f50f2 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -666,6 +666,11 @@ fn map_graph_colors(g: &mut vectorcraft_doc::GraphSpec, f: &mut dyn FnMut(&Color map_paint_colors(&mut st.fill, f); map_paint_colors(&mut st.stroke, f); } + for r in [&mut styles.values_rows, &mut styles.categories_rows, &mut styles.legend_rows].into_iter().flatten() { + for paint in [&mut r.paint.fill, &mut r.paint.stroke].into_iter().flatten() { + map_paint_colors(paint, f); + } + } } /// `f` applied to solid, gradient-stop and freeform-control colors, with any swatch link. diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index a9fb07bdf8..54da0562b1 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document: {…, units (English), unitId: the units' Preferences › Units id (points, millimeters, feetInches, …), rasterEffectsPpi}, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), fontDetails: [{font, match: exact|style|missingWithFallback|missing, family, style, file, embedding: allowed|editing|previewPrint|forbidden|null}] (each font of fonts, in order: how it was found, the face shown (null when none), that face's file name (null for a built-in face) and what its license allows), images, imageObjects: [{name, width, height, linked, file}] (the image objects in the order Linked Images and Embedded Images list them; file: what a linked image's row shows, null for an embedded one), swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient that color a part of it above opacity 0 (a stop at opacity 0 counts only where it mixes with a stop above opacity 0 over a stretch of some width), at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients, kinds}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else its kind's name, such as Linear gradient; kinds gives each one's kind, linear|radial|conic|diamond|freeform, when it is named by its kind, else null; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds", has_doc, info ), @@ -85,17 +85,65 @@ pub struct Section { /// What a font's `fsType` lets a copy of it do, read as exports read it ([`Embedding`]): the /// most permissive usage bit wins, and bitmap-only fonts may not be embedded. pub fn embedding_label(fs_type: u16) -> &'static str { - if Embedding::from_fs_type(fs_type) == Embedding::Forbidden { - "embedding not allowed" - } else if fs_type & 0x0008 != 0 { - "embedding for editing" - } else if fs_type & 0x0004 != 0 { - "embedding for preview and print" - } else { - "embedding allowed" + match embedding_id(fs_type) { + "forbidden" => "embedding not allowed", + "editing" => "embedding for editing", + "previewPrint" => "embedding for preview and print", + _ => "embedding allowed", } } +/// [`embedding_label`] as the `embedding` value of a `fontDetails` entry: `forbidden`, `editing`, +/// `previewPrint` or `allowed`. +pub fn embedding_id(fs_type: u16) -> &'static str { + if Embedding::from_fs_type(fs_type) == Embedding::Forbidden { + "forbidden" + } else if fs_type & 0x0008 != 0 { + "editing" + } else if fs_type & 0x0004 != 0 { + "previewPrint" + } else { + "allowed" + } +} + +/// A font as Font Details lists it: its `fontDetails` entry and its row. The row gives the file +/// the font is drawn from (`built in` for a face with no file) and whether its license lets it be +/// embedded; for a style the family lacks, the face shown in its place; for a font that is not +/// installed, the face shown instead, or `missing`. The entry holds the same facts as values +/// (`match`: `exact`, `style`, `missingWithFallback` or `missing`), so the panel can show them in +/// the interface language. +fn font_detail(used: &super::fonts::UsedFont) -> (Value, (String, String)) { + let db = vectorcraft_text::FontDb::global(); + let (family, style, version) = used; + let label = super::fonts::font_label(used); + // Found by any of its names (in the version the type names); a style the family lacks shows in + // another. + let resolved = db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m)); + let (entry, detail) = match resolved { + Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => { + let file = f.path().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()); + let found = format!("{}; {}", file.as_deref().unwrap_or("built in"), embedding_label(f.fs_type())); + let substituted = m == vectorcraft_text::FontMatch::Style; + let entry = json!({ + "font": label, + "match": if substituted { "style" } else { "exact" }, + "family": f.family, + "style": f.style, + "file": file, + "embedding": embedding_id(f.fs_type()), + }); + (entry, if substituted { format!("substituted: shown in {} {} ({found})", f.family, f.style) } else { found }) + } + Some((f, _)) => ( + json!({ "font": label, "match": "missingWithFallback", "family": f.family, "style": f.style, "file": null, "embedding": null }), + format!("missing: shown in {} {}", f.family, f.style), + ), + None => (json!({ "font": label, "match": "missing", "family": null, "style": null, "file": null, "embedding": null }), "missing".into()), + }; + (entry, (label, detail)) +} + /// An image object as Document Info lists it: (name, width, height, linked file). type ImageRow = (String, u32, u32, Option); @@ -131,14 +179,15 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S } /// The Document Info categories of `d` (only `category` when given) from `info` (what -/// `document.info` reports), its fonts, its image objects, the rows of the brushes its art uses, the -/// rows of its Repeat objects and the rows of its objects filled or stroked with a spot color, with a -/// pattern and with a gradient. +/// `document.info` reports), its fonts and their Font Details rows, its image objects, the rows of +/// the brushes its art uses, the rows of its Repeat objects and the rows of its objects filled or +/// stroked with a spot color, with a pattern and with a gradient. #[allow(clippy::too_many_arguments)] fn sections( d: &Document, info: &Value, fonts: &BTreeSet, + font_details: &[(String, String)], images: &[ImageRow], brushes: &[(String, String)], repeats: &[(String, String)], @@ -192,34 +241,7 @@ fn sections( "gradients" => gradient_objects.to_vec(), "symbols" => names("symbols"), "fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(), - "fontDetails" => { - let db = vectorcraft_text::FontDb::global(); - fonts - .iter() - .map(|used| { - let (family, style, version) = used; - // Found by any of its names (in the version the type names); a style the - // family lacks shows in another. - let resolved = - db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m)); - let detail = match resolved { - Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => { - let file = - f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned()); - let found = format!("{file}; {}", embedding_label(f.fs_type())); - if m == vectorcraft_text::FontMatch::Style { - format!("substituted: shown in {} {} ({found})", f.family, f.style) - } else { - found - } - } - Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style), - None => "missing".into(), - }; - (super::fonts::font_label(used), detail) - }) - .collect() - } + "fontDetails" => font_details.to_vec(), "linkedImages" => { images.iter().filter_map(|(name, w, h, link)| Some((name.clone(), format!("{w} × {h} px — {}", link.as_ref()?)))).collect() } @@ -277,6 +299,39 @@ fn gradient_label(p: &Paint) -> Option { } } +/// Where [`gradient_label`]'s name for gradient paint `p` comes from: `swatch:` for a +/// gradient linked to a swatch, else `kind:` (`linear`, `radial`, `conic`, `diamond` or +/// `freeform`). The two prefixes keep a swatch's name apart from a kind's name it reads like. +fn gradient_source(p: &Paint) -> Option { + match p { + Paint::Gradient(g) => Some(match g.swatch.as_deref().filter(|s| !s.is_empty()) { + Some(swatch) => format!("swatch:{swatch}"), + None => format!("kind:{}", g.gradient.kind.label().to_ascii_lowercase()), + }), + _ => None, + } +} + +/// For each of `names` (an object's [`gradient_label`]s), the kind it names when only a kind +/// gives that name: a name one of `sources` (the object's [`gradient_source`]s) gives as a kind's +/// and none gives as a swatch's, else null. A swatch named like a kind keeps its name even where +/// a gradient of that kind is listed under the same name. +fn gradient_kinds(names: &[String], sources: &[String]) -> Vec { + names + .iter() + .map(|name| { + if sources.iter().any(|s| s.strip_prefix("swatch:") == Some(name.as_str())) { + return Value::Null; + } + sources + .iter() + .filter_map(|s| s.strip_prefix("kind:")) + .find(|k| vectorcraft_color::GradientKind::parse(k).is_some_and(|kind| *name == format!("{} gradient", kind.label()))) + .map_or(Value::Null, |k| json!(k)) + }) + .collect() +} + /// The names of `d`'s spot color swatches: the swatches whose prints go to a plate of their own /// ([`vectorcraft_doc::inks::inks`]). A name held by several swatches is the first one's, as /// [`Document::swatch`] finds it, and the Registration swatch prints on every plate, so it is not @@ -289,10 +344,46 @@ fn spot_swatches(d: &Document) -> HashSet<&str> { first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect() } +/// Which of gradient `g`'s stops color some part of it that draws, by the stretches +/// [`vectorcraft_color::Gradient::sample`] mixes in, for stops in any order: the pair of stop `k` +/// and stop `k + 1` covers the offsets above every offset of stops `0..=k` up to stop `k + 1`'s, +/// and both its stops count when that stretch has positive width and either stop is above opacity +/// 0. The first stop also counts when it is above opacity 0, since it colors the offsets up to its +/// own, and so does the last stop, since it colors the offsets above every stop's. Any other stop, +/// opaque or not, counts only through a pair: a stop out of order whose pairs have no width +/// (another stop before it reaches past both ends) colors nothing. A freeform gradient draws from +/// its points, so each of its stops counts while any stop is above opacity 0. +fn drawn_stops(g: &vectorcraft_color::Gradient) -> Vec { + let stops = &g.stops; + if g.kind == vectorcraft_color::GradientKind::Freeform { + let any = stops.iter().any(|s| s.opacity > 0.0); + return vec![any; stops.len()]; + } + let mut drawn = vec![false; stops.len()]; + if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) { + *d = first.opacity > 0.0; + } + if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) { + *d |= last.opacity > 0.0; + } + // `f32::max` skips a NaN offset, which the sampler never picks either. + let mut reach = f32::NEG_INFINITY; + for k in 0..stops.len().saturating_sub(1) { + reach = reach.max(stops[k].offset); + let (a, b) = (&stops[k], &stops[k + 1]); + if b.offset > reach && (a.opacity > 0.0 || b.opacity > 0.0) { + drawn[k] = true; + drawn[k + 1] = true; + } + } + drawn +} + /// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is -/// linked to, or those a gradient's stops are linked to, each once, in the order of the stops. A -/// link at a tint of 0 prints no ink, a gradient whose stops are all at an opacity of 0 draws -/// nothing, and a color that only looks like a spot color is not linked to one. +/// linked to, or those a gradient's drawn stops ([`drawn_stops`]) are linked to, each once, in the +/// order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a +/// gradient at an opacity of 0 prints none, and a color that only looks like a spot color is not +/// linked to one. fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec { let mut out: Vec = vec![]; let mut add = |swatch: &Option, tint: f32| { @@ -306,7 +397,10 @@ fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec { }; match p { Paint::Solid { swatch, tint, .. } => add(swatch, *tint), - Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity > 0.0) => g.gradient.stops.iter().for_each(|s| add(&s.swatch, s.tint)), + Paint::Gradient(g) => { + let drawn = drawn_stops(&g.gradient); + g.gradient.stops.iter().zip(drawn).filter(|&(_, d)| d).for_each(|(s, _)| add(&s.swatch, s.tint)); + } _ => {} } out @@ -447,9 +541,11 @@ fn node_and_drawn<'a>(d: &'a Document, path: &[usize]) -> Option<(&'a Node, bool /// The spot colors, the patterns and the gradients (`spots`: [`spot_swatches`]) that object `n` /// paints, each once: those of its own fills and strokes ([`own_paints`], `members` included), of /// its drawn characters and, for a grayscale or 1-bit image, of the color it is colorized with. -fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec; 3] { +/// The fourth list gives where each gradient comes from: a swatch or a kind ([`gradient_source`]). +fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec; 4] { let (spot, pattern, gradient) = (|p: &Paint| spot_labels(spots, p), one_label(pattern_label), one_label(gradient_label)); - let labels: [&PaintNames<'_>; 3] = [&spot, &pattern, &gradient]; + let gradient_from = one_label(gradient_source); + let labels: [&PaintNames<'_>; 4] = [&spot, &pattern, &gradient, &gradient_from]; let painted_runs = painted_runs(d, spots, n); let mut used = labels.map(|label| own_paints(d, n, members, painted_runs.as_ref(), label)); // The characters' own paints: an inline graphic draws no outline and does not take its run's @@ -615,8 +711,10 @@ fn info(s: &mut Session, p: &Value) -> Result { if let Some(k) = k { *counts.entry(k).or_default() += 1; } - // The spot colors, the patterns and the gradients this object paints, each once; none when it does not draw. - let [used_spots, used_patterns, used_gradients] = if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() }; + // The spot colors, the patterns and the gradients this object paints, each once, and where each gradient comes from; + // none when it does not draw. + let [used_spots, used_patterns, used_gradients, used_sources] = + if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() }; // Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list. if !used_spots.is_empty() { let name = n.display_name(); @@ -636,7 +734,8 @@ fn info(s: &mut Session, p: &Value) -> Result { let name = n.display_name(); gradients.extend(used_gradients.iter().cloned()); gradient_rows.push((name.clone(), used_gradients.join(", "))); - gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients })); + let kinds = gradient_kinds(&used_gradients, &used_sources); + gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients, "kinds": kinds })); *counts.entry("gradients").or_default() += 1; } if n.extras().mask.is_some() { @@ -671,7 +770,15 @@ fn info(s: &mut Session, p: &Value) -> Result { fonts.extend(super::fonts::art_texts_of(d).into_iter().flat_map(|(_, t, _)| t.runs.iter().map(|r| super::fonts::font_of(&r.style)))); } let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); - // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two + let (font_entries, font_rows): (Vec, Vec<(String, String)>) = fonts.iter().map(font_detail).unzip(); + // The image objects in walk order, as Linked Images and Embedded Images list them: `file` is a + // linked image's row value (its path, or its file name alone when the request may not read it), + // and null for an embedded one. + let image_objects: Vec = image_rows + .iter() + .map(|(name, w, h, file)| json!({ "name": name, "width": w, "height": h, "linked": file.is_some(), "file": file })) + .collect(); + // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the first of two // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type // as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them. let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect(); @@ -688,7 +795,9 @@ fn info(s: &mut Session, p: &Value) -> Result { "objects": counts, "pathLength": path_length, "fonts": font_names, + "fontDetails": font_entries, "images": images, + "imageObjects": image_objects, "symbols": symbols, "spotColors": spot, "spotColorNames": spot, @@ -710,7 +819,7 @@ fn info(s: &mut Session, p: &Value) -> Result { }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category); + let sections = sections(d, &out, &fonts, &font_rows, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -724,6 +833,7 @@ fn document_summary(d: &Document) -> Value { "title": d.title, "colorMode": match d.color_mode { ColorMode::Rgb => "RGB", ColorMode::Cmyk => "CMYK" }, "units": d.units.label(), + "unitId": d.units.key(), "artboards": d.artboards.iter().map(|a| json!({ "name": a.name, "width": a.rect.width() / u, "height": a.rect.height() / u })).collect::>(), "rasterEffectsPpi": d.raster_effects_ppi, }) @@ -948,7 +1058,7 @@ mod tests { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().paint = Paint::None; assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([])); - // Two panel brushes with one name: the type is the one the renderer draws with, the last one's. + // Two panel brushes with one name: the type is the one the renderer draws with, the first one's. let twin = square(&mut s, 140.0); s.execute("brush.apply", &json!({"ids": [twin], "name": "Charcoal"})).unwrap(); let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); @@ -958,7 +1068,7 @@ mod tests { lib.extend(kinds.into_iter().map(|kind| vectorcraft_brush::Brush { name: "Twin".into(), kind })); vectorcraft_brush::store(d, &lib); d.node_mut(NodeId(twin)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Twin".into()); - assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "scatter"}])); + assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "art"}])); // Type: a brushed stroke on drawn characters is listed, one on spaces alone is not. let blank = s.execute("text.create", &json!({"x": 10, "y": 60, "text": " "})).unwrap()["id"].as_u64().unwrap(); let word = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); @@ -1250,11 +1360,11 @@ mod tests { assert_eq!( i["gradientObjects"], json!([ - {"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"]}, - {"id": b, "name": "", "gradients": ["Radial gradient"]}, - {"id": f, "name": "", "gradients": ["Freeform gradient"]}, - {"id": t, "name": "Type", "gradients": ["Ramp"]}, - {"id": bar, "name": "Bar", "gradients": ["Ramp"]}, + {"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"], "kinds": [null, "linear"]}, + {"id": b, "name": "", "gradients": ["Radial gradient"], "kinds": ["radial"]}, + {"id": f, "name": "", "gradients": ["Freeform gradient"], "kinds": ["freeform"]}, + {"id": t, "name": "Type", "gradients": ["Ramp"], "kinds": [null]}, + {"id": bar, "name": "Bar", "gradients": ["Ramp"], "kinds": [null]}, ]) ); assert_eq!(i["gradientNames"], json!(["Freeform gradient", "Linear gradient", "Radial gradient", "Ramp"])); @@ -1493,6 +1603,95 @@ mod tests { assert_eq!(spots(&mut s), (vec![], None, None)); } + /// Spot Color Objects leaves out a spot color linked only from gradient stops at an opacity of 0 + /// whose stretches all lead to other stops at an opacity of 0 or have no width, while another + /// part of the gradient draws: an inner stop, an end stop and a stop at the offset of an opaque + /// stop. A clear stop next to an opaque stop tints the stretch between them, so it stays listed. + /// Type whose characters draw such a gradient follows the same rule, with and without Selection + /// Only, and the report writes the same rows. + #[test] + fn spot_color_objects_skip_spot_stops_only_in_clear_stretches() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let ink = |c: f64| json!({"model": "cmyk", "c": c, "m": 0.5, "y": 0.0, "k": 0.0}); + for (k, name) in ["Ink", "Near", "Far", "Edge", "Seam"].into_iter().enumerate() { + s.execute("swatch.new", &json!({"name": name, "color": ink(0.2 * k as f64), "spot": true})).unwrap(); + } + let stop = |offset: f64, swatch: Option<&str>, opacity: f64| match swatch { + Some(sw) => json!({"offset": offset, "color": "#000", "swatch": sw, "opacity": opacity}), + None => json!({"offset": offset, "color": "#fff", "opacity": opacity}), + }; + let fill = |s: &mut Session, id: &Value, stops: Value| { + s.execute("paint.setFill", &json!({"ids": [id], "gradient": {"kind": "linear", "stops": stops}})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap(); + }; + let square = + |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + // Near borders the opaque Ink stop; Far sits between clear stops; Edge ends the ramp after a + // clear stop. + let ramp = square(&mut s, 0.0); + fill( + &mut s, + &ramp, + json!([stop(0.0, Some("Ink"), 1.0), stop(0.4, Some("Near"), 0.0), stop(0.6, Some("Far"), 0.0), stop(1.0, Some("Edge"), 0.0)]), + ); + s.execute("layer.setProps", &json!({"id": ramp, "name": "Ramp"})).unwrap(); + // Seam meets the opaque half only at one offset, where nothing between them has width. + let seam = square(&mut s, 20.0); + fill(&mut s, &seam, json!([stop(0.0, None, 0.0), stop(0.5, Some("Seam"), 0.0), stop(0.5, None, 1.0), stop(1.0, None, 1.0)])); + let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone(); + fill(&mut s, &t, json!([stop(0.0, Some("Ink"), 1.0), stop(0.5, None, 0.0), stop(1.0, Some("Far"), 0.0)])); + s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap(); + let check = |s: &mut Session, params: Value| { + let i = s.execute("document.info", ¶ms).unwrap(); + let listed: Vec<(String, Value)> = + i["spotObjects"].as_array().unwrap().iter().map(|o| (o["name"].as_str().unwrap().to_string(), o["spotColors"].clone())).collect(); + assert_eq!(listed, vec![("Ramp".to_string(), json!(["Ink", "Near"])), ("Type".to_string(), json!(["Ink"]))], "{params}"); + assert_eq!((i["spotColorNames"].clone(), i["objects"]["spots"].as_u64()), (json!(["Ink", "Near"]), Some(2)), "{params}"); + let rows = i["sections"].as_array().unwrap().iter().find(|c| c["id"] == "spotColors").unwrap()["rows"].clone(); + assert_eq!(rows, json!([["Ramp", "Ink, Near"], ["Type", "Ink"]]), "{params}"); + }; + check(&mut s, json!({})); + s.execute("select.set", &json!({"ids": [ramp, seam, t]})).unwrap(); + check(&mut s, json!({"selectionOnly": true})); + let text = s.execute("document.info", &json!({"category": "spotColors", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nSPOT COLOR OBJECTS\nRamp: Ink, Near\nType: Ink\n"), "{text}"); + assert!(!["Far", "Edge", "Seam"].iter().any(|n| text.contains(n)), "{text}"); + } + + /// A gradient's stops print where they color a stretch that draws, for stops in any order: the + /// sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it, so an + /// out-of-order stop's pair can have no width. + #[test] + fn drawn_stops_follow_the_sampled_stretches() { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientStop}; + let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) }; + let g = |stops: Vec| Gradient::new(GradientKind::Linear, stops); + // In order: the clear stop next to the opaque one, not the one after it. + assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)])), vec![true, true, false]); + // Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with + // the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8. + assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)])), vec![false, false, true, true]); + // An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the + // pairs next to the stop at 0.3 have no width and it colors nothing. + let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]); + assert_eq!(drawn_stops(&out_of_order(0.0)), vec![false; 5]); + // With the stop at 0.8 opaque, the pairs next to it with width are drawn. + assert_eq!(drawn_stops(&out_of_order(1.0)), vec![true, true, false, false, false]); + // The last stop colors the offsets above every stop's, even when it is out of order. + assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)])), vec![false, false, true]); + // The first stop colors the offsets up to its own. + assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)])), vec![true, false, false]); + // One stop, every stop clear, and no stops at all. + assert_eq!(drawn_stops(&g(vec![stop(0.5, 1.0)])), vec![true]); + assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(1.0, 0.0)])), vec![false, false]); + assert!(drawn_stops(&g(vec![])).is_empty()); + // A freeform gradient draws from its points: its stops count while any is above opacity 0. + let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]); + free.kind = GradientKind::Freeform; + assert_eq!(drawn_stops(&free), vec![true, true, true]); + } + /// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object, /// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of /// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no @@ -1672,6 +1871,112 @@ mod tests { assert_eq!(embedding_label(0x0003), "embedding not allowed"); assert_eq!(embedding_label(0x0006), "embedding for preview and print"); assert_eq!(embedding_label(0x000c), "embedding for editing"); + // `fontDetails` names each permission by id. + assert_eq!( + [0, 0x0002, 0x0104, 0x0008, 0x0200, 0x000c].map(embedding_id), + ["allowed", "forbidden", "previewPrint", "editing", "forbidden", "editing"] + ); + } + + /// `fontDetails` gives each Font Details row's facts as values, in the rows' order: how the + /// font was found, the face shown, its file (null for a built-in face) and its embedding + /// permission. The rows and the report stay in English. + #[test] + fn info_exposes_structured_font_details_without_changing_english_rows() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi"})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Heavy", "font": "Source Sans 3", "style": "Black Wide"})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Gone", "font": "Qx Absent Family"})).unwrap(); + let i = s.execute("document.info", &json!({"category": "fontDetails"})).unwrap(); + let (entries, rows) = (i["fontDetails"].as_array().unwrap(), i["sections"][0]["rows"].as_array().unwrap()); + assert_eq!(entries.iter().map(|e| e["font"].clone()).collect::>(), rows.iter().map(|r| r[0].clone()).collect::>()); + let at = |prefix: &str| entries.iter().position(|e| e["font"].as_str().unwrap().starts_with(prefix)).unwrap(); + let regular = at("Source Sans 3 Regular"); + assert_eq!( + entries[regular], + json!({"font": "Source Sans 3 Regular", "match": "exact", "family": "Source Sans 3", "style": "Regular", "file": null, "embedding": "allowed"}) + ); + assert_eq!(rows[regular][1], "built in; embedding allowed"); + let (wide, e) = (at("Source Sans 3 Black Wide"), &entries[at("Source Sans 3 Black Wide")]); + assert_eq!((e["match"].as_str(), e["family"].as_str()), (Some("style"), Some("Source Sans 3")), "{e}"); + let file = e["file"].as_str().unwrap_or("built in"); + let fs_type = [("allowed", 0), ("forbidden", 0x0002), ("previewPrint", 0x0004), ("editing", 0x0008)] + .into_iter() + .find_map(|(id, bits)| (e["embedding"] == id).then_some(bits)) + .unwrap(); + let found = format!("{file}; {}", embedding_label(fs_type)); + assert_eq!(rows[wide][1], format!("substituted: shown in Source Sans 3 {} ({found})", e["style"].as_str().unwrap())); + let (gone, e) = (at("Qx Absent Family"), &entries[at("Qx Absent Family")]); + assert!(e["file"].is_null() && e["embedding"].is_null(), "{e}"); + match e["match"].as_str() { + Some("missingWithFallback") => { + assert_eq!(rows[gone][1], format!("missing: shown in {} {}", e["family"].as_str().unwrap(), e["style"].as_str().unwrap())) + } + Some("missing") => assert_eq!((rows[gone][1].as_str(), e["family"].is_null()), (Some("missing"), true)), + m => panic!("{m:?}"), + } + let text = s.execute("document.info", &json!({"category": "fontDetails", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("Source Sans 3 Regular: built in; embedding allowed\n"), "{text}"); + } + + /// `document.unitId` names the document's units as Preferences › Units does, beside the English + /// `units`, and `imageObjects` lists the image objects in the order Embedded Images and Linked + /// Images list them, with the file a linked one shows (null for an embedded one). + #[test] + fn info_exposes_ordered_images_and_unit_id() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.edit("Units", |d, _| { + d.units = vectorcraft_doc::Unit::Millimeters; + Ok(()) + }) + .unwrap(); + for x in [0.0, 50.0] { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 10})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + } + let i = s.execute("document.info", &json!({})).unwrap(); + assert_eq!((i["document"]["units"].as_str(), i["document"]["unitId"].as_str()), (Some("Millimeters"), Some("millimeters"))); + let section = |id: &str| i["sections"].as_array().unwrap().iter().find(|s| s["id"] == id).unwrap()["rows"].as_array().unwrap().clone(); + assert!(section("document").iter().any(|r| *r == json!(["Units", "Millimeters"]))); + let embedded = section("embeddedImages"); + let objects = i["imageObjects"].as_array().unwrap(); + assert_eq!(objects.len(), 2, "{objects:?}"); + for (o, r) in objects.iter().zip(&embedded) { + assert_eq!((o["linked"].clone(), o["file"].clone(), o["name"].clone()), (json!(false), Value::Null, r[0].clone()), "{o}"); + assert_eq!(r[1], format!("{} × {} px", o["width"], o["height"])); + } + assert!(section("linkedImages").is_empty()); + } + + /// A `gradientObjects` entry's `kinds` gives the kind of each gradient listed by its kind + /// rather than by a swatch, and null for a swatch's name, even one that reads like a kind and + /// one an object's gradient of that kind shares; a type object's characters count as its own. + #[test] + fn gradient_kinds_tell_kinds_from_swatch_names() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let ramp = |kind: &str| json!({"kind": kind, "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}); + s.execute("swatch.new", &json!({"name": "Radial gradient", "gradient": ramp("linear")})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [r], "swatch": "Radial gradient"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [r], "gradient": ramp("conic")})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [t], "gradient": ramp("diamond")})).unwrap(); + // The swatch's name and a radial gradient's kind name are listed once, as the name. + let both = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [both], "swatch": "Radial gradient"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [both], "gradient": ramp("radial")})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + let entry = |id: &Value| i["gradientObjects"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap().clone(); + assert_eq!( + (entry(&r)["gradients"].clone(), entry(&r)["kinds"].clone()), + (json!(["Radial gradient", "Conic gradient"]), json!([null, "conic"])) + ); + assert_eq!((entry(&t)["gradients"].clone(), entry(&t)["kinds"].clone()), (json!(["Diamond gradient"]), json!(["diamond"]))); + assert_eq!((entry(&both)["gradients"].clone(), entry(&both)["kinds"].clone()), (json!(["Radial gradient"]), json!([null]))); } #[test] diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 400e13baab..53949fdaf0 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -110,7 +110,7 @@ pub fn specs() -> Vec { "Smooth", [], None, - "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5)} smooth selected paths near the drag → {ids, before, after}", + "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag → {ids, before, after}", has_selection, smooth_region ), diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index 0ac4fc8d6b..560990cec1 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -2,7 +2,7 @@ use vectorcraft_pathops::addons::pathtools as pt; use super::common::{dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, subpath_len, target_paths}; use super::{ - Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Result, Session, SimplifyOptions, SubPath, Value, json, + Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Rect, Result, Session, SimplifyOptions, SubPath, Value, json, number_or, po, }; @@ -15,15 +15,27 @@ const SMOOTH_SPREAD: f64 = 1.0 / 3.0; /// The options the Smooth tool refits with: the path smoothed along its length by a Gaussian of /// spread `sigma` (none at 0), then refitted within `tol` of that and within twice the distance the -/// smoothing moved it, keeping a corner only where the path turns back on itself. -fn refit_options(tol: f64, sigma: f64) -> SimplifyOptions { - SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, smooth: sigma, smooth_moved_only: true } +/// smoothing moved it, keeping a corner only where the path turns back on itself. `free` says whether +/// the start and the end of the stretch refitted are ends of an open path, whose direction then +/// follows the smoothed path within the reach rather than the first or last segment. +fn refit_options(tol: f64, sigma: f64, free: [bool; 2]) -> SimplifyOptions { + SimplifyOptions { + tolerance: tol, + corner_angle_deg: 179.0, + straight_lines: false, + smooth: sigma, + smooth_moved_only: true, + free_start: free[0], + free_end: free[1], + } } /// Smooth and refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. -fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec { +/// `free` says whether its first and last anchors are ends of an open path: the curve leaving or +/// arriving at such an end can turn. +fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64, free: [bool; 2]) -> Vec { let piece = PathData::single(SubPath::new(anchors.to_vec(), false)); - let fitted = po::simplify_with(&piece, &refit_options(tol, sigma)); + let fitted = po::simplify_with(&piece, &refit_options(tol, sigma, free)); let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() }; let (f, l) = (anchors[0], anchors[anchors.len() - 1]); let last = out.len() - 1; @@ -37,16 +49,198 @@ fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec { out } -fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Option { +/// Pieces of curve that one Smooth drag measures against the drag while it searches the segments +/// under it ([`reach_samples`]), shared equally by the curved segments that come within its reach, +/// each of which gets at least one: enough to search 300,000 pt of curve within the default reach +/// of the drag. +const MAX_REACH_PIECES: usize = 1 << 17; + +/// The box around the control points of `s`, which holds all of `s`. +fn control_box(s: CubicBez) -> Rect { + Rect::from_points(s.p0, s.p1).union_pt(s.p2).union_pt(s.p3) +} + +/// The stretches of the drag `pts` (stretch `k` runs from `pts[k]` to the next point; a drag of one +/// point has the point as its only stretch). +fn drag_stretches(pts: &[Point]) -> Vec { + (0..pts.len().saturating_sub(1).max(1)).collect() +} + +/// The stretches of the drag `pts` among `of` whose bounds come within `radius` of `r` on both +/// axes: every stretch within `radius` of a point in `r`, and perhaps a few more. +fn stretches_near(pts: &[Point], of: &[usize], r: Rect, radius: f64) -> Vec { + let r = r.inflate(radius, radius); + of.iter() + .copied() + .filter(|&k| { + let Some(&a) = pts.get(k) else { return false }; + let b = pts.get(k + 1).copied().unwrap_or(a); + a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0 + }) + .collect() +} + +/// The distance from `q` to the nearest of the stretches `of` of the drag `pts`. +fn dist_to_stretches(pts: &[Point], of: &[usize], q: Point) -> f64 { + of.iter() + .filter_map(|&k| { + let a = *pts.get(k)?; + Some(dist_to_polyline(&[a, pts.get(k + 1).copied().unwrap_or(a)], q)) + }) + .fold(f64::INFINITY, f64::min) +} + +/// The place on `s` (0 to 1) nearest to the stretches `of` of the drag `pts`, and its distance from +/// them. For each stretch the nearest place is at one of the stretch's ends, at an end of `s`, or +/// where `s` crosses the stretch's line or stops moving toward or away from it. +fn nearest_to_stretches(s: CubicBez, pts: &[Point], of: &[usize]) -> (f64, f64) { + let (c1, c2, c3) = (3.0 * (s.p1 - s.p0), 3.0 * ((s.p2 - s.p1) - (s.p1 - s.p0)), (s.p3 - s.p0) - 3.0 * (s.p2 - s.p1)); + let mut best = (0.5, f64::INFINITY); + for &k in of { + let Some(&a) = pts.get(k) else { continue }; + let b = pts.get(k + 1).copied().unwrap_or(a); + let mut places: Vec = vec![0.0, 1.0, s.nearest(a, 1e-9).t, s.nearest(b, 1e-9).t]; + let d = b - a; + if d.hypot() > 1e-12 { + let (f0, f1, f2, f3) = (d.cross(s.p0 - a), d.cross(c1), d.cross(c2), d.cross(c3)); + places.extend(kurbo::common::solve_cubic(f0, f1, f2, f3)); + places.extend(kurbo::common::solve_quadratic(f1, 2.0 * f2, 3.0 * f3)); + } + for u in places.into_iter().filter(|u| (0.0..=1.0).contains(u)) { + let dist = dist_to_polyline(&[a, b], s.eval(u)); + if dist < best.1 { + best = (u, dist); + } + } + } + best +} + +/// How many curved segments of `pd` may come within `radius` of the drag `pts`. +fn segments_in_reach(pd: &PathData, pts: &[Point], radius: f64) -> usize { + let all = drag_stretches(pts); + pd.subpaths + .iter() + .map(|sp| { + (0..sp.segment_count()) + .filter(|&i| { + !vectorcraft_tools::draw2::segment_is_straight(sp, i) && !stretches_near(pts, &all, control_box(sp.segment(i)), radius).is_empty() + }) + .count() + }) + .sum() +} + +/// Samples along the part of `c` from `t0` to `t1`, in order: (t, whether the point there is within +/// `radius` of the stretches `of` of the drag `pts`). A piece no stretch comes near gives one sample +/// out of reach; the others are halved until their control polygon is no longer than half the +/// reach, or until `budget` pieces have been measured, and then give one sample: in reach at the +/// piece's middle or at its place nearest the drag when either is in reach, otherwise out of reach +/// at its middle. Each piece measures only the stretches near +/// the piece it was cut from, so a long drag along a long curve costs about one pass over the drag +/// per halving, plus the stretches near each piece. +fn reach_samples(c: CubicBez, (t0, t1): (f64, f64), (pts, of): (&[Point], &[usize]), radius: f64, budget: &mut usize, out: &mut Vec<(f64, bool)>) { + *budget = budget.saturating_sub(1); + let s = c.subsegment(t0..t1); + let mid = 0.5 * (t0 + t1); + let near = stretches_near(pts, of, control_box(s), radius); + if near.is_empty() { + out.push((mid, false)); + return; + } + let len = s.p0.distance(s.p1) + s.p1.distance(s.p2) + s.p2.distance(s.p3); + if len <= (radius / 2.0).max(1e-3) || *budget == 0 || mid <= t0 || mid >= t1 { + // The piece's middle, or the place on it nearest the drag when the middle is out of reach. + let sample = if dist_to_stretches(pts, &near, c.eval(mid)) <= radius { + (mid, true) + } else { + let (u, dist) = nearest_to_stretches(s, pts, &near); + if dist <= radius { (t0 + u * (t1 - t0), true) } else { (mid, false) } + }; + out.push(sample); + return; + } + reach_samples(c, (t0, mid), (pts, &near), radius, budget, out); + reach_samples(c, (mid, t1), (pts, &near), radius, budget, out); +} + +/// `sp` cut where the reach `radius` of the drag `pts` crosses its curved segments, and whether each +/// anchor of the result is in reach. An anchor in reach is added in the middle of each stretch of a +/// segment in reach that does not run on from one of the segment's anchors in reach, and an anchor +/// out of reach in the middle of each stretch out of reach between two stretches in reach, so that a +/// stretch in reach is smoothed even when the segment's anchors are not, and the stretches between +/// stay as they are. A segment is searched no more than half the reach apart where it comes within +/// the reach of the drag ([`reach_samples`]), measuring up to `share` pieces of each segment. +fn mark_reached(sp: &SubPath, pts: &[Point], radius: f64, share: usize) -> (SubPath, Vec) { + let near = |q: Point| dist_to_polyline(pts, q) <= radius; let n = sp.anchors.len(); let marked: Vec = sp.anchors.iter().map(|a| near(a.p)).collect(); - if n < 3 || !marked.iter().any(|m| *m) { + let mut places = vec![]; + let all = drag_stretches(pts); + for i in 0..sp.segment_count() { + if vectorcraft_tools::draw2::segment_is_straight(sp, i) { + continue; + } + let c = sp.segment(i); + let (mut samples, mut budget) = (vec![], share); + reach_samples(c, (0.0, 1.0), (pts, &all), radius, &mut budget, &mut samples); + // Stretches of samples alike, in order: (in reach, first, last). + let mut runs: Vec<(bool, usize, usize)> = vec![]; + for (k, s) in samples.iter().enumerate() { + match runs.last_mut() { + Some(r) if r.0 == s.1 => r.2 = k, + _ => runs.push((s.1, k, k)), + } + } + let last = samples.len().saturating_sub(1); + let (start, end) = (marked[i], marked[(i + 1) % n]); + for (j, &(reached, first, to)) in runs.iter().enumerate() { + let mid = samples[(first + to) / 2].0; + let joined = (first == 0 && start) || (to == last && end); + if reached && !joined { + places.push((i, mid)); + } + // A gap with reach on both sides: the segment's anchor or a stretch on each side. + let before = if j == 0 { start } else { runs[j - 1].0 }; + let after = runs.get(j + 1).map_or(end, |r| r.0); + if !reached && before && after { + places.push((i, mid)); + } + } + } + // A place on one of the segment's ends would name that anchor rather than add one. + places.retain(|(_, t)| (1e-5..=1.0 - 1e-5).contains(t)); + if places.is_empty() { + return (sp.clone(), marked); + } + let (out, added) = pt::insert_at(sp, &places); + // The anchors already there keep their marks. A new anchor split off at a sample in reach can + // land a rounding error farther away than the sample. + let mut kept = marked.into_iter(); + let marked = out + .anchors + .iter() + .enumerate() + .map(|(j, a)| if added.contains(&j) { dist_to_polyline(pts, a.p) <= radius + 1e-9 * radius.max(1.0) } else { kept.next().unwrap_or(false) }) + .collect(); + (out, marked) +} + +fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f64, sigma: f64) -> Option { + if sp.anchors.len() < 2 { + return None; + } + let near = &|q: Point| dist_to_polyline(pts, q) <= radius; + // A drag along a segment smooths it even when its anchors are out of reach. + let (sp, marked) = mark_reached(sp, pts, radius, share); + let sp = &sp; + if !marked.iter().any(|m| *m) { return None; } if sp.closed { let Some(k) = marked.iter().position(|m| !m) else { // Everything is in range: smooth the whole closed path, across its start too. - let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma)); + let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2])); return f.subpaths.into_iter().next(); }; // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. @@ -57,14 +251,14 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f open.anchors.push(open.anchors[0]); m.push(false); open.closed = false; - let mut res = smooth_open(&open.anchors, &m, near, tol, sigma); + let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, false); let l = res.pop()?; if let Some(r0) = res.first_mut() { r0.h_in = l.h_in; } return Some(SubPath::new(res, true)); } - Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma), false)) + Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma, true), false)) } /// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it @@ -96,8 +290,10 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez /// `anchors` with each run of `marked` ones smoothed by a Gaussian of spread `sigma` and refitted /// within `tol`, together with the stretch of the segments leading into the run that is in the /// drag's reach (`near`): a short drag smooths what is under it, and the rest of a long segment next -/// to it stays as it was (#1512). -fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Vec { +/// to it stays as it was (#1512). With `open`, `anchors` are an open path, and the curve at its first +/// or last anchor turns with the smoothing when a run's refit reaches that anchor; a run's other ends, +/// and those of the open run a closed path is cut into, keep their direction. +fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, open: bool) -> Vec { let n = anchors.len(); let mut runs = vec![]; let mut i = 0; @@ -115,7 +311,7 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool for (a, b) in runs.into_iter().rev() { let lo = a.saturating_sub(1); let hi = (b + 1).min(n - 1); - if hi - lo < 2 { + if hi == lo { continue; } let mut piece = out[lo..=hi].to_vec(); @@ -137,7 +333,8 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool head = Some(Anchor { h_out: far.p1, ..*outer }); *outer = Anchor::with_handles(far.p3, far.p2, reach.p1); } - let new = refit_run(&piece, tol, sigma); + let free = [open && lo == 0 && head.is_none(), open && hi == n - 1 && tail.is_none()]; + let new = refit_run(&piece, tol, sigma, free); out.splice(lo..=hi, head.into_iter().chain(new).chain(tail)); } out @@ -147,19 +344,24 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { const C: &str = "path.smoothRegion"; let pts = points_param(p, "points", C)?; let radius = number_or(p, "radius", 12.0, C)?.max(0.0); - let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0); + let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_TOLERANCE, vectorcraft_tools::draw2::SMOOTH_MAX_TOLERANCE); let sigma = radius * SMOOTH_SPREAD; let st = s.doc()?; - let near = |q: Point| dist_to_polyline(&pts, q) <= radius; let mut plan = vec![]; let (mut before, mut after) = (0, 0); + let mut paths = vec![]; for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, radius)) { - let (pd, _) = path_of(&st.doc, id)?; + paths.push((id, path_of(&st.doc, id)?.0)); + } + // Each curved segment in reach gets the same share of the search, whatever order they come in. + let in_reach: usize = paths.iter().map(|(_, pd)| segments_in_reach(pd, &pts, radius)).sum(); + let share = (MAX_REACH_PIECES / in_reach.max(1)).max(1); + for (id, pd) in paths { let mut changed = false; let subs: Vec = pd .subpaths .iter() - .map(|sp| match smooth_subpath(sp, &near, tol, sigma) { + .map(|sp| match smooth_subpath(sp, &pts, radius, share, tol, sigma) { Some(n) if n != *sp && n.anchors.len() >= 2 => { changed = true; n @@ -414,3 +616,37 @@ pub(super) fn join_scrub(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({ "id": ida.0 })) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Searching a curve 1,000,000 pt long under a drag of 1,000 points along it measures no more + /// pieces than the budget it is given, and a search with none left gives one sample. + #[test] + fn searching_a_segment_under_a_drag_stays_within_its_budget() { + let c = CubicBez::new(Point::new(0.0, 0.0), Point::new(300000.0, 0.0), Point::new(700000.0, 0.0), Point::new(1000000.0, 0.0)); + let pts: Vec = (0..1000).map(|i| Point::new(f64::from(i) * 1000.0, 0.0)).collect(); + let all = drag_stretches(&pts); + let mut budget = MAX_REACH_PIECES; + let (mut first, mut second) = (vec![], vec![]); + reach_samples(c, (0.0, 1.0), (&pts, &all), 12.0, &mut budget, &mut first); + reach_samples(c, (0.0, 1.0), (&pts, &all), 12.0, &mut budget, &mut second); + assert!(first.len() <= MAX_REACH_PIECES, "{} samples", first.len()); + assert_eq!((budget, second.len()), (0, 1), "the budget is spent, so the next search gives one sample"); + } + + /// A small piece of curve is measured only against the few stretches of a drag of 100,000 + /// points that come near it, and a drag of one point has that point as its stretch. + #[test] + fn a_small_piece_is_measured_against_the_stretches_near_it() { + let pts: Vec = (0..100_000).map(|i| Point::new(f64::from(i) * 5.0, 0.0)).collect(); + let all = drag_stretches(&pts); + let near = stretches_near(&pts, &all, Rect::new(1000.0, 3.0, 1004.0, 6.0), 12.0); + assert_eq!(near, (197..=203).collect::>()); + assert!((dist_to_stretches(&pts, &near, Point::new(1002.0, 5.0)) - 5.0).abs() < 1e-12); + let one = [Point::new(10.0, 10.0)]; + assert_eq!(drag_stretches(&one), vec![0]); + assert!((dist_to_stretches(&one, &[0], Point::new(13.0, 14.0)) - 5.0).abs() < 1e-12); + } +} diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 4e2e9a85fd..382f06bc5f 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -133,7 +133,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[ name: "outlineText", ty: "boolean", default: "false", - description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) and type with rotated characters are always outlines", + description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, warichu, inline graphics, gradient or pattern fills) and type with rotated characters are always outlines", }, ]; diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index ec7d43472d..c834e653e4 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), @@ -305,6 +305,15 @@ pub(super) fn encode_art_page(cmd: &str, doc: &Document, p: &Value) -> Result = placed.into_iter().map(Note::loss).collect(); let (bytes, drawn) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; notes.extend(drawn); + if notes.iter().any(|n| n.text.ends_with(SHOWN_SMALLER)) && options(cmd, &full, p)?.settings.standard == Standard::None { + for n in notes.iter_mut().filter(|n| n.text.ends_with(SHOWN_SMALLER)) { + n.text.push_str(LONG_PAGES_IN_POINTS); + } + } notes.extend(overflow_warning(cmd, &full, p)?.map(Note::loss)); Ok((bytes, notes)) } diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 91fe9701e7..bd52fe71c7 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -103,6 +103,70 @@ fn use_artboards_false_writes_one_page_of_the_arts_bounds() { } } +/// In a PDF without a standard, the warning for a page longer than 14,400 pt written with a +/// `UserUnit` above 1 names "Write pages longer than 200 in at full size in points": at PDF 1.5 +/// (written as 1.6) and later, through `document.exportPdf`, `document.export`, a page of the +/// art's bounds and the Summary's export, with one such warning per page. PDF/A files and `.ai` +/// saves keep the warning without the option's name. Pages written in points (the option on, PDF +/// 1.4, PDF/X, a page too narrow for a `UserUnit` above 1) and a page too long and narrow for +/// 14,400 units have other warnings, which don't name it. +#[test] +fn a_page_with_a_user_unit_names_the_option_that_writes_it_in_points() { + use super::pdf::LONG_PAGES_IN_POINTS; + const SMALLER: &str = "so apps that ignore UserUnit show it that many times smaller"; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20_000, "height": 1_000})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 19_000, "height": 500})).unwrap(); + let warnings = |v: &Value| -> Vec { + v["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).filter(|w| w.contains("UserUnit")).map(str::to_string).collect() + }; + let named = |v: &Value| warnings(v).iter().any(|w| w.contains(LONG_PAGES_IN_POINTS)); + for p in [ + json!({"longPagesInPoints": false}), + json!({"longPagesInPoints": false, "compatibility": "1.5"}), + json!({"longPagesInPoints": false, "compatibility": "2.0"}), + json!({"longPagesInPoints": false, "useArtboards": false}), + ] { + let mut q = p.clone(); + q["includeDocument"] = json!(true); + for v in [export(&mut s, p.clone()), s.execute("document.export", &as_export(&p)).unwrap(), s.execute("document.pdfSettings", &q).unwrap()] { + let page: Vec = warnings(&v).into_iter().filter(|w| w.starts_with("the page of artboard")).collect(); + assert_eq!(page.len(), 1, "{p}: {v}"); + assert!(page[0].ends_with(&format!("{SMALLER}{LONG_PAGES_IN_POINTS}")), "{p}: {page:?}"); + assert_eq!(warnings(&v).iter().filter(|w| w.contains("apps that ignore UserUnit")).count(), 1, "{p}: {v}"); + } + } + for p in [ + json!({"longPagesInPoints": true}), + json!({"longPagesInPoints": false, "compatibility": "1.4"}), + json!({"longPagesInPoints": false, "preset": "PDF/X-4:2010"}), + ] { + assert!(!named(&export(&mut s, p.clone())), "{p}"); + } + for p in + [json!({"longPagesInPoints": false, "standard": "pdfA2b"}), json!({"longPagesInPoints": false, "standard": "pdfA2b", "compatibility": "1.4"})] + { + let v = export(&mut s, p.clone()); + assert!(warnings(&v).iter().any(|w| w.ends_with(SMALLER)) && !named(&v), "{p}: {v}"); + } + let ai = s.execute("document.export", &json!({"format": "ai", "longPagesInPoints": false})).unwrap(); + assert!(warnings(&ai).iter().any(|w| w.ends_with(SMALLER)) && !named(&ai), "{ai}"); + // The art's bounds are within 14,400 pt. + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 500, "height": 500})).unwrap(); + assert!(!named(&export(&mut s, json!({"longPagesInPoints": false, "useArtboards": false})))); + // A page 3 pt high keeps a UserUnit of 1, so it is written in points either way; a page + // 85,040 × 17 pt gets a UserUnit above 1 that leaves its long side past 14,400 units. + for (w, h) in [(20_000, 3), (85_040, 17)] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": w, "height": h})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": w - 1_000, "height": h})).unwrap(); + let v = export(&mut s, json!({"longPagesInPoints": false})); + assert!(!named(&v), "{w} × {h}: {v}"); + } +} + /// A PDF of the art's bounds carries no Preserve Editing data, through `document.exportPdf`, /// `document.export` and the Summary's export in memory, and says so in a warning: reopened, its /// editing data would replace the document's artboards with the one around the art. A PDF of the diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 005427ded8..7c8f664cf3 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -1136,37 +1136,6 @@ fn font_list(_: &mut Session, p: &Value) -> Result { } } -/// Gives the inline graphics in the type of [`art_texts_of`] the current bounds of their symbols' -/// art, and lays out again the type whose inline graphics changed. A symbol drawn inline in the -/// art of another symbol takes one more round, so there is at most one round per symbol. -fn resolve_art_inline(doc: &mut Document) { - for _ in 0..=doc.symbols.len() { - let names: BTreeSet = - art_texts_of(doc).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))).collect(); - let bounds: BTreeMap> = names - .into_iter() - .map(|name| { - let b = doc.symbol_art_bounds(&name); - (name, b) - }) - .collect(); - let stale = |t: &TextObject| t.runs.iter().any(|r| r.inline.as_ref().is_some_and(|a| bounds.get(&a.symbol).is_some_and(|b| *b != a.bounds))); - let mut changed = false; - edit_art_texts_of(doc, &stale, &mut |t| { - for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { - if let Some(b) = bounds.get(&a.symbol) { - a.bounds = *b; - } - } - refresh_bounds(t); - changed = true; - }); - if !changed { - break; - } - } -} - pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); #[cfg(not(target_arch = "wasm32"))] @@ -1188,8 +1157,7 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { d.change_unsaved(|doc| { let doc = std::sync::Arc::make_mut(doc); edit_art_texts_of(doc, &|_| true, &mut refresh_bounds); - resolve_art_inline(doc); - doc.resolve_inline_art(); + doc.resolve_inline_art_with(&mut refresh_bounds); for id in texts { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { refresh_bounds(t); diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 0f72b066ab..bdba729211 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Add Freeform Point", [], None, - "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}", + "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (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 → {index}", has_doc, add_point ), diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 98f53b102e..f454d7133b 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -49,6 +49,10 @@ //! member with another order, and any other change to the list (items moved, or several added //! or removed at once), takes the model's whole list of fills and strokes. //! +//! A path directly in a graph series that takes an edit from another member (a graph's paths are +//! members only while the graph is isolated) also has what changed stored on its graph, so that +//! every path of the series takes it when the graph is drawn again (docs/mcp.md). +//! //! Members that are hidden or locked at the time are left as they are. An edit that would take any //! member past the canvas (beyond [`vectorcraft_doc::MAX_COORD`]) is refused whole, as one that //! takes the selection there is. Whenever the document is put back (Undo, Redo, a cancelled drag @@ -432,16 +436,24 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> { let [id] = st.selection.objects.as_slice() else { return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into()); }; - // A group's contents are checked when Global Edit starts: measuring them here would walk - // every object in it each time the menu or the Properties panel asks. Other objects are - // measured ([`supported`]), not reduced to their form: the Control bar asks every frame. - match st.doc.node(*id) { - Some(n) if n.extras().mask.is_none() && plain_group(n) => Ok(()), - Some(n) if supported(n) => Ok(()), - _ => Err(UNSUPPORTED.into()), - } + // The object, a group's contents included, is measured ([`supported`]), not reduced to its + // form, and the answer is kept until the document's revision moves: the Control bar asks + // every frame, and a group's walk takes time with the anchors in it. + let ok = match st.global_edit_support.get() { + Some((rev, key, ok)) if rev == st.revision && key == *id => ok, + _ => { + let ok = st.doc.node(*id).is_some_and(supported); + st.global_edit_support.set(Some((st.revision, *id, ok))); + ok + } + }; + if ok { Ok(()) } else { Err(UNSUPPORTED.into()) } } +/// [`can_toggle`]'s answer for the one selected object: the revision and the object it was found +/// for, and whether Global Edit supports it. +pub type SupportCache = std::cell::Cell>; + fn toggle(s: &mut Session, p: &Value) -> Result { if s.doc()?.global_edit.is_some() { let st = s.doc_mut()?; @@ -778,9 +790,22 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se } super::object::keep_compound_aligned(d, *id, Scaling::default()); } + keep_series_changes(before, d, &edited); edited } +/// Store on their graphs what the members `edited`, and the objects in them, took on the paths directly in a graph +/// series, for the graph's next redraw. The edited member is left to the edit's own command. +fn keep_series_changes(before: &Document, d: &mut Document, edited: &[NodeId]) { + let mut taken = vec![]; + for id in edited { + if let Some(n) = d.node(*id) { + n.walk(&mut |c: &Node| taken.push(c.id)); + } + } + super::appearance::capture_series_changes(before, d, &taken); +} + /// The outlines of the members of the active document's Global Edit that are not selected, hold /// no selected object and sit inside no selected object, in their layer's colour: the canvas /// highlights them while Global Edit is on. While it is off, with one supported object selected, @@ -989,8 +1014,7 @@ mod tests { continue; } asked += 1; - let today = n.extras().mask.is_none() && plain_group(&n) || by_form(&n); - assert_eq!(can_toggle(&s).is_ok(), today, "seed {seed}"); + assert_eq!(can_toggle(&s).is_ok(), by_form(&n), "seed {seed}"); match s.execute(C, &json!({})) { Ok(_) => { assert!(by_form(&n), "seed {seed}"); diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 18bb3469b9..54ca07da7e 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient 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", has_doc, edit_gradient ), @@ -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)", + "{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", has_doc, set_gradient_geom ), diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index c45bbf5a67..e283afd752 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -45,9 +45,9 @@ use generate::generate; #[cfg(test)] use series::default_series_color; pub(crate) use series::{ - capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity, - capture_series_paints, capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_object_opacity_differs, - shown_paint, + LabelTextChange, capture_label_anti_alias, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles, + capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, label_object_opacity_differs, + paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index b115714256..290e099500 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -73,6 +73,44 @@ pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role kept.unwrap_or_default() } +/// The object rows labels of `role` keep, if any. +fn kept_rows(styles: &vectorcraft_doc::LabelStyles, role: Role) -> Option<&vectorcraft_doc::LabelRows> { + match role { + Role::Value => styles.values_rows.as_ref(), + Role::Category => styles.categories_rows.as_ref(), + Role::Legend => styles.legend_rows.as_ref(), + } +} + +/// Give label `n` the object rows `kept` keeps: a fill row and a stroke row for the kept paints, in their kept order and +/// place relative to the characters, and the object opacity and blend mode. An opacity read from a file is held to 0..1 +/// and a weight to 0..1000 pt; one that is not a number is left out (a stroke then takes 1 pt). +fn apply_label_rows(n: &mut Node, kept: &vectorcraft_doc::LabelRows) { + use vectorcraft_doc::{AppearanceItem, FillLayer, StrokeLayer}; + let rows = &kept.paint; + let unit = |o: Option| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0)); + let fill = rows.fill.as_ref().map(|paint| { + let mut f = FillLayer::new(paint.clone()); + f.opacity = unit(rows.fill_opacity).unwrap_or(1.0); + f.blend = rows.fill_blend.unwrap_or_default(); + AppearanceItem::Fill(f) + }); + let stroke = rows.stroke.as_ref().map(|paint| { + let width = rows.stroke_width.filter(|w| w.is_finite()).map_or(1.0, |w| w.clamp(0.0, 1000.0)); + let mut st = StrokeLayer::new(paint.clone(), width); + st.opacity = unit(rows.stroke_opacity).unwrap_or(1.0); + st.blend = rows.stroke_blend.unwrap_or_default(); + AppearanceItem::Stroke(st) + }); + let items: Vec = if kept.stroke_under { [stroke, fill] } else { [fill, stroke] }.into_iter().flatten().collect(); + let mut ap = Appearance::clone(&n.appearance); + ap.items = items; + ap.set_contents_at(usize::from(kept.below.unwrap_or(0))); + n.appearance = ap.into(); + n.opacity = unit(rows.opacity).unwrap_or(1.0); + n.blend = rows.blend.unwrap_or_default(); +} + /// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it. pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 { label_style(styles, role).size @@ -90,7 +128,15 @@ impl Gen<'_> { } pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc { let t = self.text_object(role, at, s, justify); - Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + self.label_node(role, t) + } + /// Label text `t` of `role` as a node, with the object rows its kind keeps. + fn label_node(&mut self, role: Role, t: TextObject) -> Arc { + let mut n = Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))); + if let Some(rows) = kept_rows(self.styles, role) { + apply_label_rows(&mut n, rows); + } + Arc::new(n) } /// Label `s` of `role` set at `at` and laid out, not yet in the document. fn text_object(&self, role: Role, at: Point, s: &str, justify: Justify) -> TextObject { @@ -121,7 +167,7 @@ impl Gen<'_> { t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf; } } - Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + self.label_node(role, t) } pub(super) fn group(&mut self, name: &str, children: Vec>) -> Arc { let mut g = Node::group(self.d.alloc_id(), children); diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 99a7ec084b..bfde9e881a 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -103,6 +103,25 @@ fn series_members(doc: &Document) -> HashMap { out } +/// The objects directly in a series group among `roots` and inside them, for an edit that reaches graph series marks +/// without naming them. They may include objects of groups that only look like series groups; the capture functions +/// check each one. +pub(crate) fn series_marks_in(doc: &Document, roots: &[NodeId]) -> Vec { + let mut out = Vec::new(); + for id in roots { + if doc.parent_of(*id).and_then(|p| doc.node(p)).is_some_and(|g| g.extras().series_index.is_some()) { + out.push(*id); + } + let Some(root) = doc.node(*id) else { continue }; + root.walk(&mut |n| { + if n.extras().series_index.is_some() { + out.extend(n.children().into_iter().flatten().map(|c| c.id)); + } + }); + } + out +} + /// Group Selection on a graph series targets its constituent marks and legend swatch, not a /// group-level appearance which the generated art does not inherit. Other ids pass through. pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec { @@ -161,29 +180,6 @@ pub(crate) fn series_group_marks_where(doc: &Document, ids: &[NodeId], stands: i out } -/// Copy the paint just applied to generated series art into that series' fill or stroke. Called -/// inside the same document edit, so the visible paint and future regeneration undo together. -/// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour. -/// Returns whether a series' captured fill or stroke changed. -pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bool) -> bool { - let members = series_members(doc); - if members.is_empty() { - return false; - } - let captures: Vec<_> = ids - .iter() - .filter_map(|id| { - // Only a series' own marks set its paint, not the art of a marker design. - let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; - let node = doc.node(*id)?; - let paint = node.appearance.paint_at(None, fill)?.clone(); - let width = (!fill).then(|| node.appearance.stroke_width()); - Some((r.graph, r.index, fill, paint, width)) - }) - .collect(); - store_series_paints(doc, captures) -} - /// The paint of the topmost visible fill (`true`) or stroke (`false`) of `node`, and a stroke's weight (0 without paint). /// With no such row: no paint. pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) { @@ -196,7 +192,8 @@ pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) { /// Copy the paint of the topmost visible fill (`true`) or stroke (`false`), and a stroke's weight, of generated series /// art just edited (`edited`: each object with the kind of row, later entries replacing earlier ones) into that series' -/// fill or stroke; art left without such a row keeps no paint. A series that draws Filled Lines bands stores no stroke: its +/// fill or stroke; art left without such a row keeps no paint. Gradients, patterns and swatch links are kept as +/// [`Paint`], not reduced to a solid color. A series that draws Filled Lines bands stores no stroke: its /// bands keep their own outline. Called inside the same document edit, so both undo together. Returns whether a series' /// captured fill or stroke changed. pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool { @@ -249,13 +246,35 @@ fn series_draws_bands(g: &GraphSpec, i: usize) -> bool { } } -/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a -/// series' captured fill or stroke changed. -fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option)>) -> bool { +/// Each graph's stored series paints before an edit's first capture into it. A later capture can put back what an earlier +/// one changed, so an edit compares what it leaves with these ([`settle_series_paints`]). +type SeriesPaintsBefore = HashMap>; + +/// What an edit gave the characters of graph label `id`, applied to a character style ([`capture_label_text`]). +pub(crate) type LabelTextChange<'a> = &'a dyn Fn(NodeId, &mut CharStyle); + +/// Does any graph in `before` now store other series paints than it did before the edit's first capture? Slots the +/// captures added past the old end and left empty are removed again, so they do not count as a change. +fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool { let mut changed = false; + for (graph, was) in before { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let empty = vectorcraft_doc::SeriesPaint::default(); + if spec.series_paints.get(was.len()..).is_some_and(|added| added.iter().all(|slot| *slot == empty)) { + spec.series_paints.truncate(was.len()); + } + changed |= spec.series_paints != was; + } + changed +} + +/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. 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 { + let mut before = SeriesPaintsBefore::new(); for (graph, index, fill, paint, width) 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 }; - let was = spec.series_paints.clone(); + before.entry(graph).or_insert_with(|| spec.series_paints.clone()); // `index` is below the graph's series count (`series_members`). if spec.series_paints.len() <= index { spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); @@ -267,9 +286,8 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P slot.stroke = Some(paint); slot.stroke_width = width; } - changed |= spec.series_paints != was; } - changed + settle_series_paints(doc, before) } /// Copy the opacity and blend mode of the topmost visible fill (`true`) or stroke (`false`) of generated series art just @@ -296,14 +314,14 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity), (blend != BlendMode::Normal).then_some(blend))) }) .collect(); - let mut changed = false; + let mut before = SeriesPaintsBefore::new(); for (graph, index, fill, kept, blend) 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 }; // A fully opaque Normal row on a series the graph keeps nothing for leaves the graph as it is. if kept.is_none() && blend.is_none() && spec.series_paints.len() <= index { continue; } - let was = spec.series_paints.clone(); + before.entry(graph).or_insert_with(|| spec.series_paints.clone()); // `index` is below the graph's series count (`series_members`). if spec.series_paints.len() <= index { spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); @@ -314,32 +332,30 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool } else { (slot.stroke_opacity, slot.stroke_blend) = (kept, blend); } - changed |= spec.series_paints != was; } - changed + settle_series_paints(doc, before) } /// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity /// and blend mode. Called inside the same document edit, so both undo together. Returns whether a series' kept object /// opacity or blend mode changed. pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool { - let mut changed = false; + let mut before = SeriesPaintsBefore::new(); for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; // A fully opaque Normal object on a series the graph keeps nothing for leaves the graph as it is. if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index { continue; } - let was = spec.series_paints.clone(); + before.entry(graph).or_insert_with(|| spec.series_paints.clone()); // `index` is below the graph's series count (`series_members`). if spec.series_paints.len() <= index { spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); } let Some(slot) = spec.series_paints.get_mut(index) else { continue }; (slot.opacity, slot.blend) = (opacity, blend); - changed |= spec.series_paints != was; } - changed + settle_series_paints(doc, before) } /// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying @@ -509,6 +525,149 @@ pub(crate) fn capture_label_paragraphs( Ok(changed) } +/// The object rows graph label `n` shows ([`vectorcraft_doc::LabelRows`]): the paint, weight, opacity and blend mode of +/// its topmost visible fill and stroke row, its object opacity and blend mode, and where those rows paint. A placed +/// gradient is kept without its placement, so it fits each label it lands on. `None`: no visible row, opaque and Normal. +pub(crate) fn label_rows(n: &Node) -> Option { + use vectorcraft_doc::AppearanceItem; + let items = &n.appearance.items; + let top = |fill: bool| items.iter().rposition(|it| it.is_fill() == fill && it.visible()); + let opacity = |o: f32| (o != 1.0).then_some(o); + let blend = |b: BlendMode| (b != BlendMode::Normal).then_some(b); + let unplaced = crate::cmd::gradient::unplaced; + let mut rows = vectorcraft_doc::SeriesPaint { opacity: opacity(n.opacity), blend: blend(n.blend), ..Default::default() }; + let (fill, stroke) = (top(true), top(false)); + if let Some(f) = fill.and_then(|i| items.get(i)) { + (rows.fill, rows.fill_opacity, rows.fill_blend) = (Some(unplaced(f.paint())), opacity(f.opacity()), blend(f.blend())); + } + if let Some(AppearanceItem::Stroke(st)) = stroke.and_then(|i| items.get(i)) { + (rows.stroke, rows.stroke_width, rows.stroke_opacity, rows.stroke_blend) = + (Some(unplaced(&st.paint)), Some(st.width), opacity(st.opacity), blend(st.blend)); + } + let contents = n.appearance.contents_at(); + let below = [fill, stroke].into_iter().flatten().filter(|i| *i < contents).fold(0u8, |k, _| k + 1); + let rows = vectorcraft_doc::LabelRows { + paint: rows, + below: (below > 0).then_some(below), + stroke_under: matches!((fill, stroke), (Some(f), Some(s)) if s < f), + }; + (rows != vectorcraft_doc::LabelRows::default()).then_some(rows) +} + +/// Keep the object rows ([`label_rows`]) of graph labels `ids` for every label of their kind, as +/// [`capture_label_styles`] keeps a character format, even when a label already showed them: the last label of a kind +/// in the order of `ids` sets what that kind keeps. Objects other than type are left out before the graph's labels are +/// looked up. Called inside the same edit, so they undo together. Returns whether kept rows changed. +pub(crate) fn capture_label_rows(doc: &mut Document, ids: &[NodeId]) -> bool { + store_label_rows(doc, ids, false) +} + +/// Keep the object opacity and blend mode of graph labels `ids` for every label of their kind, as [`capture_label_rows`] +/// keeps them, and leave the kept fill and stroke rows as they are. Returns whether what the graph keeps changed. +pub(crate) fn capture_label_object_opacity(doc: &mut Document, ids: &[NodeId]) -> bool { + store_label_rows(doc, ids, true) +} + +/// Would [`capture_label_object_opacity`] change what a graph keeps for labels `ids`? Reads the document without copying +/// it. +pub(crate) fn label_object_opacity_differs(doc: &Document, ids: &[NodeId]) -> bool { + label_rows_of(doc, ids).into_iter().any(|(graph, role, rows)| { + let Some(spec) = doc.node(graph).and_then(|n| n.extras().graph.as_deref()) else { return false }; + let slot = match role { + Role::Value => &spec.label_styles.values_rows, + Role::Category => &spec.label_styles.categories_rows, + Role::Legend => &spec.label_styles.legend_rows, + }; + *slot != kept_label_rows(slot, rows, true) + }) +} + +/// [`capture_label_rows`], or with `object_only` [`capture_label_object_opacity`]. +fn store_label_rows(doc: &mut Document, ids: &[NodeId], object_only: bool) -> bool { + let mut changed = false; + for (graph, role, rows) in label_rows_of(doc, ids) { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let slot = match role { + Role::Value => &mut spec.label_styles.values_rows, + Role::Category => &mut spec.label_styles.categories_rows, + Role::Legend => &mut spec.label_styles.legend_rows, + }; + let kept = kept_label_rows(slot, rows, object_only); + changed |= *slot != kept; + *slot = kept; + } + changed +} + +/// What a kind keeps after a label of it shows `rows`: those rows, or with `object_only` what `slot` keeps with the +/// label's object opacity and blend mode. +fn kept_label_rows( + slot: &Option, + rows: Option, + object_only: bool, +) -> Option { + if !object_only { + return rows; + } + let mut kept = slot.clone().unwrap_or_default(); + let shown = rows.unwrap_or_default(); + (kept.paint.opacity, kept.paint.blend) = (shown.paint.opacity, shown.paint.blend); + (kept != vectorcraft_doc::LabelRows::default()).then_some(kept) +} + +/// The graph, kind and rows ([`label_rows`]) of the last label of each kind among `ids`. +fn label_rows_of(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role, Option)> { + // Graph labels are type: without type among `ids`, the graph's labels are not looked up. + if !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) { + return vec![]; + } + let labels = graph_labels(doc); + let mut picked: Vec<(NodeId, Role, Option)> = vec![]; + for (&(g, role), n) in ids.iter().rev().filter_map(|id| Some((labels.get(id)?, doc.node(*id)?))) { + if !picked.iter().any(|(pg, pr, _)| *pg == g && *pr == role) { + picked.push((g, role, label_rows(n))); + } + } + picked +} + +/// Keep what an edit gave all of the text of graph labels `ids` for every label of their kind: `change`, when given (the +/// character attributes the edit applied to label `id`), on the kind's kept style, as [`capture_label_styles`] keeps a +/// character format, and `para`, when given, as the kind's paragraph attributes ([`capture_label_paragraphs`]). +/// Attributes the edit did not apply stay as the kind keeps them. Returns whether a kept style or set of attributes +/// changed. +pub(crate) fn capture_label_text( + doc: &mut Document, + ids: &[NodeId], + change: Option, + para: Option<&vectorcraft_doc::ParaStyle>, +) -> Result { + if (change.is_none() && para.is_none()) || !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) { + return Ok(false); + } + // The last label of each kind sets what the kind keeps. + let labels = graph_labels(doc); + let mut last: Vec<((NodeId, Role), NodeId)> = vec![]; + for id in ids.iter().rev() { + if let Some(kind) = labels.get(id).filter(|k| !last.iter().any(|(l, _)| l == *k)) { + last.push((*kind, *id)); + } + } + let mut changed = false; + for id in last.iter().rev().map(|(_, id)| id) { + if let Some(change) = change { + changed |= capture_label_styles(doc, &[*id], &|st| change(*id, st)); + } + if let Some(para) = para { + changed |= capture_label_paragraphs(doc, &[*id], &|p| { + *p = para.clone(); + Ok(()) + })?; + } + } + Ok(changed) +} + /// The graphs and kinds of label among `ids`, each once. fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> { if ids.is_empty() { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index e847326e88..85e63afce1 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -572,6 +572,124 @@ fn an_object_opacity_a_series_mark_already_shows_replaces_the_kept_opacity() { assert!(spec(&s, id).series_paints.iter().all(|p| p.opacity.is_none())); } +/// Randomize Opacity on marks of one series stores the opacity of the frontmost of them on the graph in the same undo +/// step, and every mark of the series has it when the graph is drawn again. +#[test] +fn a_random_opacity_on_series_marks_survives_regeneration() { + let (mut s, id, _, marks) = column_marks(); + s.execute("addon.arrange.randomizeOpacity", &json!({"ids": [marks[1].0, marks[0].0], "min": 30, "max": 60, "seed": 7})).unwrap(); + let front = s.doc().unwrap().doc.node(marks[1]).unwrap().opacity; + assert!(front < 1.0); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(front)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == front), "{:?}", transparency_of(&s, id, 0)); + assert!(transparency_of(&s, id, 1).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 1)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the opacities and what the graph stored"); +} + +/// Sort Objects giving series marks new opacities stores the opacity of the frontmost of them on the graph, in place of +/// the one another mark left there. +#[test] +fn an_opacity_sort_objects_gives_series_marks_survives_regeneration() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[2].0, "opacity": 30})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + // From left to right the three columns get 30%, 65% and 100%: the frontmost, the rightmost, is fully opaque. + let ids = [marks[0].0, marks[1].0, marks[2].0]; + s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap(); + let opacities: Vec = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect(); + assert_eq!(opacities, [0.3, 0.65, 1.0]); + assert_eq!(spec(&s, id).series_paints[0].opacity, None); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3), "undo takes back the sort and what the graph stored"); +} + +/// Sort Objects stores the opacity of the frontmost series mark whose opacity it changes: a mark in front that keeps its +/// opacity leaves the stored value to the marks it changed. +#[test] +fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 60})).unwrap(); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 30})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + // From left to right the columns trade 60%, 30% and 100% for 30%, 60% and 100%: the frontmost keeps its 100%. + let ids = [marks[0].0, marks[1].0, marks[2].0]; + s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity"})).unwrap(); + let opacities: Vec = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect(); + assert_eq!(opacities, [0.3, 0.6, 1.0]); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6)); +} + +/// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark +/// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them +/// when the graph is drawn again. +#[test] +fn what_global_edit_gives_series_marks_survives_regeneration() { + let (mut s, id, _, _) = column_marks(); + // Equal values: the first column of each series has the same shape. + s.execute("graph.setData", &json!({"rows": [[3, 3], [5, 2]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (first, other) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id); + s.execute("object.isolate", &json!({"id": id.0})).unwrap(); + s.execute("select.set", &json!({"ids": [first.0]})).unwrap(); + let r = s.execute("select.globalEdit", &json!({})).unwrap(); + assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}"); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("transparency.set", &json!({"ids": [first.0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#e8573f"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [first.0], "color": "#1f4e9c"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [first.0], "weight": 5})).unwrap(); + let mark = s.doc().unwrap().doc.node(other).unwrap().clone(); + assert_eq!((mark.opacity, mark.blend), (0.4, BlendMode::Multiply), "Global Edit reaches the other series' mark"); + let kept = spec(&s, id).series_paints[1].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply))); + assert_eq!(kept.fill, Some(mark.appearance.fill_paint())); + assert_eq!((kept.stroke, kept.stroke_width), (Some(mark.appearance.stroke_paint()), Some(5.0))); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let normal = BlendMode::Normal; + assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 1)); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == mark.appearance.fill_paint())); + while s.doc().unwrap().history.undo.len() > steps { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(spec(&s, id).series_paints.iter().all(|p| *p == vectorcraft_doc::SeriesPaint::default()), "{:?}", spec(&s, id).series_paints); +} + +/// With a whole series group picked and Global Edit on, the other series groups of the same form take the edit of its +/// marks, and the graph stores it for their series too. +#[test] +fn what_global_edit_gives_the_marks_of_another_series_group_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Series without names draw no legend, so each series group holds only its columns, laid out like the other's. + let csv = ",,\nQ1,3,3\nQ2,5,5"; + let made = s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap(); + let id = NodeId(made["id"].as_u64().unwrap()); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (first, other) = (series(&n, 0).clone(), series(&n, 1).id); + s.execute("object.isolate", &json!({"id": id.0})).unwrap(); + s.execute("select.set", &json!({"ids": [first.id.0]})).unwrap(); + let r = s.execute("select.globalEdit", &json!({})).unwrap(); + assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}"); + let marks: Vec = first.children().unwrap().iter().map(|m| m.id.0).collect(); + s.execute("paint.setFill", &json!({"ids": marks, "color": "#e8573f"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let given = series(&n, 1).children().unwrap()[0].appearance.fill_paint(); + assert_eq!(given, series(&n, 0).children().unwrap()[0].appearance.fill_paint(), "Global Edit reaches the other group"); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(given.clone())); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == given)); +} + /// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of /// the series again; Normal forgets it. #[test] @@ -1381,7 +1499,7 @@ fn removing_a_filled_line_bands_stroke_leaves_its_color() { let (mut s, id, band) = band_graph(); let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); s.execute("paint.setStroke", &json!({"ids": [band.0], "none": true})).unwrap(); - assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None)); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none())); assert_eq!(redrawn_band(&mut s, id).0, was); } @@ -2209,6 +2327,459 @@ fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() { assert_eq!(spec(&s, id).series_paints[0].fill, red); } +/// A column graph whose series 0 stores a red fill and a red 2 pt stroke set on its second mark, while the topmost fill +/// (over a visible one) and the green stroke of its first mark are hidden: the graph and the first mark. +fn hidden_top_rows() -> (Session, NodeId, NodeId) { + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); + let (_, upper) = fill_rows(&s, marks[0]); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3, "visible": false})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], false), "color": "#ff0000", "weight": 2})).unwrap(); + (s, id, marks[0]) +} + +/// `paint.setFill`, `paint.setStroke`, `paint.none`, Apply Fill to Stroke and Apply Stroke to Fill write the topmost row +/// of the kind even when it is hidden. The mark shows what it showed before, and the series keeps the paint it stored. +#[test] +fn a_paint_set_on_a_hidden_top_row_leaves_the_stored_paint() { + let cases = [ + ("paint.setFill", json!({"color": "#0000ff"})), + ("paint.setFill", json!({"none": true})), + ("paint.setStroke", json!({"color": "#0000ff"})), + ("paint.setStroke", json!({"none": true})), + ("paint.none", json!({})), + ("addon.color.fillToStroke", json!({})), + ("addon.color.strokeToFill", json!({})), + ]; + every_case(cases, |(cmd, params)| { + let (mut s, id, mark) = hidden_top_rows(); + let was = spec(&s, id).series_paints; + let red = was[0].fill.clone().unwrap(); + assert_eq!((was[0].stroke.clone(), was[0].stroke_width), (Some(red.clone()), Some(2.0))); + let mut p = params.clone(); + if *cmd == "paint.none" { + s.execute("select.set", &json!({"ids": [mark.0]})).unwrap(); + } else { + p["ids"] = json!([mark.0]); + } + let before = s.doc().unwrap().doc.node(mark).unwrap().clone(); + s.execute(cmd, &p).unwrap(); + assert_ne!(*s.doc().unwrap().doc.node(mark).unwrap(), before, "the command wrote the hidden row"); + assert_eq!(spec(&s, id).series_paints, was); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| *m == (red.clone(), red.clone(), 2.0)), "{:?}", marks_of(&s, id, 0)); + }); +} + +/// `paint.setFill` with `item` on the visible fill under a hidden topmost one changes what the mark shows, and the series +/// stores it. On a fill under a visible topmost one, the series keeps the paint of the topmost one. +#[test] +fn a_paint_set_on_a_row_under_the_top_one_is_stored_only_when_it_shows() { + let (mut s, id, mark) = hidden_top_rows(); + let (lower, _) = fill_rows(&s, mark); + s.execute("paint.setFill", &json!({"ids": [mark.0], "item": lower, "color": "#0000ff"})).unwrap(); + let blue = s.doc().unwrap().doc.node(mark).unwrap().appearance.paint_at(Some(lower), true).cloned(); + assert!(blue.as_ref().is_some_and(|p| !p.is_none())); + assert_eq!(spec(&s, id).series_paints[0].fill, blue); + s.execute("edit.undo", &json!({})).unwrap(); + assert_ne!(spec(&s, id).series_paints[0].fill, blue); + + // The first mark has a green fill over another one, and the series stores the red fill of the second mark. + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); + let (lower, upper) = fill_rows(&s, marks[0]); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "color": "#00ff00"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap(); + let red = Some(fill_of(&s, marks[1])); + assert_ne!(red, Some(fill_of(&s, marks[0]))); + assert_eq!(spec(&s, id).series_paints[0].fill, red); + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "item": lower, "color": "#0000ff"})).unwrap(); + assert_ne!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.paint_at(Some(lower), true), red.as_ref()); + assert_eq!(spec(&s, id).series_paints[0].fill, red); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| Some(&m.0) == red.as_ref()), "{:?}", marks_of(&s, id, 0)); +} + +/// A library gradient applied to a selected mark goes to the topmost fill as `paint.setFill` puts it there: the series +/// stores it from a visible row, and keeps its stored paint when that row is hidden. +#[test] +fn a_library_gradient_on_a_series_mark_is_stored_only_from_a_row_it_shows() { + for hidden in [false, true] { + let (mut s, id, mark) = hidden_top_rows(); + if !hidden { + s.execute("appearance.showAllHidden", &json!({"ids": [mark.0]})).unwrap(); + } + let lib = s.execute("library.create", &json!({})).unwrap()["id"].as_str().unwrap().to_string(); + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 20})).unwrap()["id"].clone(); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "stops": stops}, "ids": [rect]})).unwrap(); + s.execute("select.set", &json!({"ids": [rect]})).unwrap(); + s.execute("library.add", &json!({"library": lib, "kind": "fillGradient"})).unwrap(); + s.execute("select.set", &json!({"ids": [mark.0]})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("library.use", &json!({"library": lib, "kind": "fillGradient", "item": "Gradient"})).unwrap(); + let fill = fill_of(&s, mark); + assert!(matches!(fill, Paint::Gradient(_)), "{hidden}"); + let now = spec(&s, id).series_paints; + if hidden { + assert_eq!(now, was); + } else { + assert_eq!(now[0].fill, Some(fill)); + } + } +} + +/// A mark's fill and stroke rows already have the paint a command writes, while the series stores the paint of another +/// mark. `paint.setFill`, `paint.setStroke`, Apply Fill to Stroke and Apply Stroke to Fill leave the mark as it was, and +/// the series stores the mark's paint for the kind of row the command wrote, and only for that kind. +#[test] +fn writing_the_paint_a_mark_already_has_stores_it_for_the_series() { + let cases = [ + ("paint.setFill", json!({"color": "#ff0000"}), true), + ("paint.setStroke", json!({"color": "#ff0000"}), false), + ("addon.color.fillToStroke", json!({}), false), + ("addon.color.strokeToFill", json!({}), true), + ("addon.color.fillToStroke", json!({"skipNone": true}), false), + ]; + every_case(cases, |(cmd, params, fill)| { + let (mut s, id, _, marks) = column_marks(); + for (mark, hex, weight) in [(marks[0], "#ff0000", 3), (marks[1], "#0000ff", 2)] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": hex, "weight": weight})).unwrap(); + } + let was = spec(&s, id).series_paints; + let (red, blue) = (fill_of(&s, marks[0]), fill_of(&s, marks[1])); + assert_eq!((was[0].fill.clone(), was[0].stroke.clone(), was[0].stroke_width), (Some(blue.clone()), Some(blue.clone()), Some(2.0))); + let before = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let mut p = params.clone(); + p["ids"] = json!([marks[0].0]); + s.execute(cmd, &p).unwrap(); + assert_eq!(*s.doc().unwrap().doc.node(marks[0]).unwrap(), before, "the mark is as it was"); + let now = spec(&s, id).series_paints; + let expected = if *fill { (Some(red), Some(blue), Some(2.0)) } else { (Some(blue), Some(red), Some(3.0)) }; + assert_eq!((now[0].fill.clone(), now[0].stroke.clone(), now[0].stroke_width), expected); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + }); +} + +/// Apply Fill to Stroke and Apply Stroke to Fill on a mark store the paint they give its topmost visible row, and every +/// mark of the series has it after the graph is drawn again. With `skipNone`, a mark without the source paint keeps what +/// the series stores. +#[test] +fn applying_one_paint_kind_to_the_other_on_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#0000ff"})).unwrap(); + let blue = fill_of(&s, marks[0]); + s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints; + assert_eq!((kept[0].fill.clone(), kept[0].stroke.clone()), (Some(blue.clone()), Some(blue.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, &m.1) == (&blue, &blue)), "{:?}", marks_of(&s, id, 0)); + + let (mut s, id, marks) = stored_red_stroke(); + let red = spec(&s, id).series_paints[0].stroke.clone(); + s.execute("addon.color.strokeToFill", &json!({"ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, red); + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "none": true})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#0000ff"})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0], "skipNone": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// When one paint command writes several marks of a series with different paints, the frontmost mark sets what the +/// graph stores, whatever order the ids are given in and whatever the marks' ids are. +#[test] +fn a_paint_command_on_several_marks_of_a_series_stores_the_frontmost_ones_paint() { + every_case([[0, 1, 2], [2, 1, 0], [1, 2, 0]], |order| { + let (mut s, id, _, marks) = column_marks(); + // The mark made last goes to the back, so the marks' ids are not in paint order. + s.execute("select.set", &json!({"ids": [marks[2].0]})).unwrap(); + s.execute("object.arrange.sendToBack", &json!({})).unwrap(); + let drawn: Vec = series_parts(&s, id, 0).iter().map(|m| m.id).filter(|m| marks[..3].contains(m)).collect(); + assert_eq!(drawn, [marks[2], marks[0], marks[1]]); + for (mark, hex) in [(marks[0], "#ff0000"), (marks[1], "#00ff00"), (marks[2], "#0000ff")] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + } + s.execute("addon.color.fillToStroke", &json!({"ids": order.map(|i| marks[i].0)})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(fill_of(&s, marks[1])), "{order:?}"); + }); + + // The frontmost mark loses its stroke and a mark under it takes one: the series stores the frontmost mark's. + let (mut s, id, marks) = stored_red_stroke(); + s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], false), "color": "#00ff00", "weight": 2})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [marks[2].0], "none": true})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#0000ff"})).unwrap(); + s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0, marks[2].0]})).unwrap(); + assert!(!s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.stroke_paint().is_none()); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None)); +} + +/// Apply Fill to Stroke on two marks whose strokes already have their fills, while the series stores the front mark's +/// stroke: the back mark's stroke is taken first and the front mark's after it, so the graph ends up storing what it +/// stored before. The document and its undo history stay as they are. +#[test] +fn a_paint_command_that_leaves_a_series_stored_paint_as_it_was_records_no_undo_step() { + let (mut s, id, _, marks) = column_marks(); + for (mark, hex, weight) in [(marks[0], "#ff0000", 3), (marks[1], "#0000ff", 2)] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": hex, "weight": weight})).unwrap(); + } + let drawn: Vec = series_parts(&s, id, 0).iter().map(|m| m.id).filter(|m| marks[..2].contains(m)).collect(); + assert_eq!(drawn, [marks[0], marks[1]], "the second mark is in front"); + let was = s.doc().unwrap().doc.clone(); + let blue = fill_of(&s, marks[1]); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(blue)); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("addon.color.fillToStroke", &json!({"ids": [marks[1].0, marks[0].0]})).unwrap(); + assert_eq!(s.doc().unwrap().doc, was); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + +/// A stroke set on a Filled Lines band with `paint.setStroke` or Apply Fill to Stroke is not stored: the band keeps its +/// color and its thin black outline when the graph is drawn again. A fill set with `paint.setFill` is stored. +#[test] +fn a_paint_command_stroke_on_a_filled_line_band_does_not_repaint_it() { + every_case([("paint.setStroke", json!({"color": "#ff0000"})), ("addon.color.fillToStroke", json!({}))], |(cmd, params)| { + let (mut s, id, band) = band_graph(); + let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); + let mut p = params.clone(); + p["ids"] = json!([band.0]); + s.execute(cmd, &p).unwrap(); + assert!(!s.doc().unwrap().doc.node(band).unwrap().appearance.stroke_paint().is_none()); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none())); + assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); + }); + let (mut s, id, band) = band_graph(); + s.execute("paint.setFill", &json!({"ids": [band.0], "color": "#0000ff"})).unwrap(); + let blue = s.doc().unwrap().doc.node(band).unwrap().appearance.fill_paint(); + assert_eq!(redrawn_band(&mut s, id), (blue, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); +} + +/// `paint.setStroke` with None on a marker that shows no stroke changes nothing, and the line keeps the stroke the series +/// stores. On the line itself it stores the empty stroke. +#[test] +fn removing_the_stroke_of_a_mark_without_one_leaves_the_stored_stroke() { + let (mut s, id, line, marker) = line_and_marker(); + s.execute("paint.setStroke", &json!({"ids": [line.0], "color": "#ff0000"})).unwrap(); + let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone())); + s.execute("paint.setStroke", &json!({"ids": [marker.0], "none": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone())); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(line_parts(&s, id).0.appearance.stroke_paint(), red); + + let line = line_parts(&s, id).0.id; + s.execute("paint.setStroke", &json!({"ids": [line.0], "none": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None)); +} + +/// Run `case` on each of `cases`, and fail naming every case that failed, not only the first one. +fn every_case(cases: impl IntoIterator, case: impl Fn(&T)) { + let failed: Vec = cases.into_iter().filter(|c| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case(c))).is_err()).collect(); + assert!(failed.is_empty(), "failed cases: {failed:?}"); +} + +/// The top fill paint of `mark`. +fn fill_of(s: &Session, mark: NodeId) -> Paint { + s.doc().unwrap().doc.node(mark).unwrap().appearance.fill_paint() +} + +/// A stroke weight set with `stroke.set`, or stepped with `stroke.weightUp` or `stroke.weightDown`, on a series mark is +/// stored on the graph with the mark's stroke paint, and undo takes it back with its edit. +#[test] +fn a_stroke_weight_set_on_a_series_mark_is_kept() { + let cases = [("stroke.set", json!({"weight": 7}), 7.0), ("stroke.weightUp", json!({}), 3.0), ("stroke.weightDown", json!({}), 1.0)]; + every_case(cases, |(cmd, params, weight)| { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + let mut p = params.clone(); + p["ids"] = json!([marks[1].0]); + s.execute(cmd, &p).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone(); + let (fill, stroke) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint()); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(stroke.clone()), Some(*weight)), "{cmd}"); + series_0_is(&mut s, id, (&fill, 0.4, &stroke, *weight), &was); + }); +} + +/// A gradient made on a series mark's fill with `paint.editGradient` is stored on the graph, every mark of the series has +/// that fill after the graph is drawn again, and undo takes it back with its edit. +#[test] +fn a_gradient_edit_on_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + s.execute("paint.editGradient", &json!({"ids": [marks[0].0], "kind": "linear"})).unwrap(); + let fill = fill_of(&s, marks[0]); + assert!(matches!(fill, Paint::Gradient(_)), "{fill:?}"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + let red = was[0].stroke.clone().unwrap(); + 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. +#[test] +fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() { + 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(); + let fill = fill_of(&s, marks[0]); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + 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)); +} + +/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph. +#[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())); + 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)); +} + +/// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command, +/// a color filter or Recolor Artwork is stored on the graph, and undo takes it back with its edit. +#[test] +fn a_recolored_series_mark_is_kept() { + let cases = [ + ("paint.invert", json!({}), false), + ("paint.complement", json!({}), false), + ("paint.invert", json!({}), true), + ("paint.complement", json!({}), true), + ("edit.colors.invert", json!({}), false), + ("edit.colors.toGrayscale", json!({}), false), + ("addon.color.filter", json!({"effect": "addon.color.invert"}), false), + ("recolor.apply", json!({"map": {"#3366cc": "#00ff00"}}), false), + ]; + every_case(cases, |(cmd, params, item)| { + let (mut s, id, marks) = stored_red_stroke(); + // Not the gray the series starts in: Complement leaves a gray as it is. + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#3366cc"})).unwrap(); + let was = spec(&s, id).series_paints; + let mut p = params.clone(); + if *cmd == "recolor.apply" { + // Recolor Artwork works on the selection. + s.execute("select.set", &json!({"ids": [marks[0].0]})).unwrap(); + } else { + p["ids"] = json!([marks[0].0]); + } + if *item { + p["item"] = json!(top_row(&s, marks[0], true)); + } + let was_fill = fill_of(&s, marks[0]); + s.execute(cmd, &p).unwrap(); + let fill = fill_of(&s, marks[0]); + assert_ne!(fill, was_fill, "{cmd} recolors the fill"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()), "{cmd} item {item}"); + let red = was[0].stroke.clone().unwrap(); + series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was); + }); +} + +/// The ink limit of Print Ink Fixes on a series mark's fill is stored on the graph. +#[test] +fn an_ink_limit_on_a_series_mark_is_kept() { + let mut s = Session::new(); + s.execute("file.new", &json!({"colorMode": "cmyk"})).unwrap(); + let id = graph(&mut s, "column"); + let mark = series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap()[0].id; + // `paint.setFill` stores the heavy color before the limit lowers it. + s.execute("paint.setFill", &json!({"ids": [mark.0], "color": {"c": 100, "m": 100, "y": 100, "k": 100}})).unwrap(); + let heavy = fill_of(&s, mark); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(heavy.clone())); + s.execute("addon.color.printFixes", &json!({"ids": [mark.0], "inkLimit": 300})).unwrap(); + let fill = fill_of(&s, mark); + assert_ne!(fill, heavy, "the limit lowers the ink"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.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 == fill), "{:?}", marks_of(&s, id, 0)); +} + +/// A recolor of a whole graph, or of one series group, stores the new fill of each series it reaches, and the marks have +/// it after the graph is drawn again. +#[test] +fn recoloring_a_graph_or_a_series_group_stores_its_marks_fills() { + let cases = [("edit.colors.invert", true), ("edit.colors.invert", false), ("paint.invert", true), ("paint.invert", false)]; + every_case(cases, |(cmd, whole)| { + let (mut s, id, gray, _) = column_marks(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (group, other) = (series(&n, 0).id, series(&n, 1).children().unwrap()[0].clone()); + let target = if *whole { id } else { group }; + s.execute(cmd, &json!({"ids": [target.0]})).unwrap(); + let fill = marks_of(&s, id, 0).last().unwrap().0.clone(); + assert_ne!(fill, gray, "{cmd} recolors the series"); + let kept = spec(&s, id).series_paints; + assert_eq!(kept[0].fill, Some(fill.clone()), "{cmd} whole {whole}"); + let other_now = fill_of(&s, other.id); + if *whole { + assert_ne!(other_now, other.appearance.fill_paint()); + assert_eq!(kept[1].fill, Some(other_now.clone())); + } else { + assert_eq!(other_now, other.appearance.fill_paint(), "the other series is left as it was"); + assert!(kept.get(1).is_none_or(|p| p.fill.is_none()), "{kept:?}"); + } + 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 == fill), "{:?}", marks_of(&s, id, 0)); + assert!(marks_of(&s, id, 1).iter().all(|m| m.0 == other_now), "{:?}", marks_of(&s, id, 1)); + }); +} + +/// A recolor that changes several marks of one series to different fills stores the frontmost mark's fill, whatever +/// order the ids are given in. +#[test] +fn recoloring_several_marks_of_a_series_stores_the_frontmost_ones_fill() { + let cases = + [("edit.colors.invert", false), ("paint.invert", true)].into_iter().flat_map(|c| [[0, 1, 2], [2, 1, 0], [1, 2, 0]].map(|o| (c.0, c.1, o))); + every_case(cases, |(cmd, item, order)| { + let (mut s, id, _, marks) = column_marks(); + for (mark, hex) in [(marks[0], "#ff0000"), (marks[1], "#00ff00"), (marks[2], "#0000ff")] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + } + let mut p = json!({"ids": order.map(|i| marks[i].0)}); + if *item { + p["item"] = json!(top_row(&s, marks[0], true)); + } + s.execute(cmd, &p).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill_of(&s, marks[2])), "{cmd} {order:?}"); + }); +} + +/// Blend Horizontally across a series' three marks stores the fill it gives the middle one on the graph. +#[test] +fn blending_a_series_marks_fills_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + for (mark, hex) in [(marks[0], "#ff0000"), (marks[2], "#0000ff")] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + } + let was = spec(&s, id).series_paints; + s.execute("select.set", &json!({"ids": [marks[0].0, marks[1].0, marks[2].0]})).unwrap(); + s.execute("edit.colors.blendHorizontally", &json!({})).unwrap(); + let mid = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint(); + assert_ne!(Some(&mid), was[0].fill.as_ref()); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(mid.clone())); + let red = was[0].stroke.clone().unwrap(); + series_0_is(&mut s, id, (&mid, 0.4, &red, 2.0), &was); +} + /// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. #[test] fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { @@ -4626,6 +5197,407 @@ fn formatting_one_label_keeps_that_kind_of_label() { assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none()); } +/// Each Axes and Legend text after the graph is drawn again, with its object rows (fill or stroke, color, opacity, and +/// for a stroke its weight) and its object opacity. +type RowLooks = Vec<(bool, String, f32, f64)>; +fn redrawn_label_rows(s: &mut Session, id: NodeId) -> Vec<(String, RowLooks, f32)> { + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = vec![]; + for g in ["Axes", "Legend"] { + for c in group(&n, g).children().unwrap() { + if let NodeKind::Text(t) = &c.kind { + let rows = c + .appearance + .items + .iter() + .map(|it| { + let w = match it { + vectorcraft_doc::AppearanceItem::Stroke(st) => st.width, + vectorcraft_doc::AppearanceItem::Fill(_) => 0.0, + }; + (it.is_fill(), it.paint().color().map(|c| c.to_hex()).unwrap_or_default(), it.opacity(), w) + }) + .collect(); + out.push((t.plain_text(), rows, c.opacity)); + } + } + } + out +} + +fn rows_of(looks: &[(String, RowLooks, f32)], text: &str) -> (RowLooks, f32) { + looks.iter().find(|(t, _, _)| t == text).map(|(_, r, o)| (r.clone(), *o)).unwrap_or_else(|| panic!("no label {text}")) +} + +/// Fill and stroke rows given to one label with the Appearance commands are kept for every label of its kind when the +/// graph is drawn again, through save and open, and undo with the change. +#[test] +fn appearance_rows_on_one_label_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // A category label: a red fill row at 50%. + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000", "opacity": 50})).unwrap(); + // A value axis number: a 2 pt blue stroke row. + let v = text_id(&s, id, "Axes", "4"); + s.execute("appearance.addStroke", &json!({"ids": [v.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [v.0], "index": 0, "color": "#0000ff", "weight": 2})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q1"), (vec![(true, "#ff0000".into(), 0.5, 0.0)], 1.0), "every category label: {looks:?}"); + assert_eq!(rows_of(&looks, "0"), (vec![(false, "#0000ff".into(), 1.0, 2.0)], 1.0), "every value axis number: {looks:?}"); + assert_eq!(rows_of(&looks, "2024"), (vec![], 1.0), "the legend keeps none: {looks:?}"); + // One undo takes the data change back, the next the stroke's color and weight together with what the graph keeps. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).label_styles.values_rows.unwrap(); + assert_ne!(kept.paint.stroke.and_then(|p| p.color()).map(|c| c.to_hex()), Some("#0000ff".into())); + assert_eq!(kept.paint.stroke_width, Some(1.0)); + s.execute("edit.redo", &json!({})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-label-rows").join("labels.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert_eq!(g.label_styles.categories_rows.unwrap().paint.fill_opacity, Some(0.5)); + assert_eq!(g.label_styles.values_rows.unwrap().paint.stroke_width, Some(2.0)); + assert!(serde_json::to_value(vectorcraft_doc::LabelStyles::default()).unwrap().as_object().unwrap().is_empty()); +} + +/// Removing a label's rows, clearing its appearance or hiding its row leaves its kind with what the label then shows. +#[test] +fn removing_a_labels_rows_changes_what_its_kind_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap(); + s.execute("appearance.removeItem", &json!({"ids": [c.0], "index": 0})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories_rows, None, "no row left"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000", "visible": false})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories_rows, None, "a hidden row shows nothing"); + s.execute("appearance.showAllHidden", &json!({"ids": [c.0]})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q3").0, vec![(true, "#ff0000".into(), 1.0, 0.0)], "{looks:?}"); + let c = text_id(&s, id, "Axes", "Q1"); + s.execute("appearance.clear", &json!({"ids": [c.0]})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q3").0, vec![(true, String::new(), 1.0, 0.0), (false, String::new(), 1.0, 1.0)], "{looks:?}"); +} + +/// A fill row painted with `paint.setFill {item}` is kept with the label's rows, and its characters keep their own fill. +#[test] +fn painting_a_labels_row_keeps_the_row_and_not_its_characters() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let l = text_id(&s, id, "Legend", "2025"); + s.execute("appearance.addFill", &json!({"ids": [l.0]})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [l.0], "item": 0, "color": "#00ff00"})).unwrap(); + let g = spec(&s, id); + assert_eq!(g.label_styles.legend_rows.and_then(|r| r.paint.fill).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#00ff00".into())); + assert_eq!(g.label_styles.legend, None, "the characters keep the default fill"); + let looks = label_looks(&mut s, id); + assert!(looks.iter().find(|(t, _, _)| t == "2024").is_some_and(|(_, _, fill)| fill == "#000000"), "{looks:?}"); +} + +/// Moving or copying an appearance onto a label (Layers panel drag) keeps the source's rows and object opacity for the +/// label's kind. +#[test] +fn an_appearance_moved_onto_a_label_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#12ab34"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [r.0], "opacity": 40})).unwrap(); + let v = text_id(&s, id, "Axes", "2"); + s.execute("appearance.transfer", &json!({"source": r.0, "target": v.0, "copy": true})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + let (rows, opacity) = rows_of(&looks, "6"); + assert_eq!((rows.first().map(|r| r.1.clone()), opacity), (Some("#12ab34".into()), 0.4), "{looks:?}"); +} + +/// The Eyedropper onto a label keeps the characters' style, the paragraph attributes and the appended rows it gave the +/// label for every label of its kind. +#[test] +fn the_eyedropper_on_a_label_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap(); + let v = text_id(&s, id, "Axes", "4"); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one step"); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, size, fill)| t == "0" && *size == 14.0 && fill == "#00ff00"), "every value axis number: {looks:?}"); + assert!(looks.iter().any(|(t, size, fill)| t == "Q1" && *size == 9.0 && fill == "#000000"), "other labels: {looks:?}"); + // Appended rows (Shift+Alt-click) go with the label's rows. + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#abcdef"})).unwrap(); + let c = text_id(&s, id, "Axes", "Q1"); + s.execute("appearance.copyFrom", &json!({"source": r.0, "ids": [c.0], "append": true})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert!(rows_of(&looks, "Q3").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#abcdef"), "{looks:?}"); +} + +/// Kept rows read from a saved graph's label styles are held to the ranges the controls keep to, and a place for the +/// Characters row past the kept rows puts every row below the characters. +#[test] +fn kept_label_rows_from_a_file_stay_in_range() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let paint = |hex: &str| serde_json::to_value(Paint::solid(vectorcraft_color::Color::from_hex(hex).unwrap())).unwrap(); + let saved = json!({"legendRows": { + "fill": paint("#ff0000"), "fillOpacity": 7.0, + "stroke": paint("#0000ff"), "strokeWidth": 1e9, "strokeOpacity": -2.0, + "opacity": -3.0, "below": 9, + }}); + let styles: vectorcraft_doc::LabelStyles = serde_json::from_value(saved).unwrap(); + s.edit("rows", |d, _| { + d.node_mut(id).unwrap().extras_mut().graph.as_deref_mut().unwrap().label_styles = styles; + Ok(()) + }) + .unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "2024"), (vec![(true, "#ff0000".into(), 1.0, 0.0), (false, "#0000ff".into(), 0.0, 1000.0)], 0.0), "{looks:?}"); + assert_eq!(label_node(&s, id, "Legend", "2024").appearance.contents_at(), 2); +} + +/// Label `text` of group `grp` of graph `id`. +fn label_node(s: &Session, id: NodeId, grp: &str, text: &str) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(text_id(s, id, grp, text)).unwrap().clone() +} + +/// An edit that leaves a label showing what it showed still sets what its kind keeps. +#[test] +fn a_label_already_showing_a_row_still_sets_what_its_kind_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap(); + redrawn_label_rows(&mut s, id); + let a = text_id(&s, id, "Axes", "Q1"); + s.execute("appearance.setItem", &json!({"ids": [a.0], "index": 0, "color": "#0000ff"})).unwrap(); + // Q3 shows the red the graph drew it with. + let b = text_id(&s, id, "Axes", "Q3"); + s.execute("appearance.setItem", &json!({"ids": [b.0], "index": 0, "color": "#ff0000"})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q1").0, vec![(true, "#ff0000".into(), 1.0, 0.0)], "{looks:?}"); +} + +/// Paint given to a label's characters leaves the rows its kind keeps as they are. +#[test] +fn paint_on_a_labels_characters_leaves_the_rows_its_kind_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap(); + redrawn_label_rows(&mut s, id); + let a = text_id(&s, id, "Axes", "Q1"); + s.execute("appearance.setItem", &json!({"ids": [a.0], "index": 0, "color": "#0000ff"})).unwrap(); + // Q3 still shows red; its characters take green. + let b = text_id(&s, id, "Axes", "Q3"); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); + // The Eyedropper from type, transparency included, onto its characters. + let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [b.0]})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q1").0, vec![(true, "#0000ff".into(), 1.0, 0.0)], "{looks:?}"); +} + +/// When labels of one kind are edited together, the last one in the order of `ids` sets what the kind keeps. +#[test] +fn the_last_label_of_a_kind_edited_together_sets_what_it_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let (a, b) = (text_id(&s, id, "Axes", "Q1"), text_id(&s, id, "Axes", "Q2")); + for (l, color) in [(a, "#ff0000"), (b, "#0000ff")] { + s.execute("appearance.addFill", &json!({"ids": [l.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [l.0], "index": 0, "color": color})).unwrap(); + } + let kept = |s: &Session| spec(s, id).label_styles.categories_rows.unwrap().paint.fill.unwrap().color().unwrap().to_hex(); + s.execute("appearance.addFill", &json!({"ids": [a.0, b.0]})).unwrap(); + assert_eq!(kept(&s), "#0000ff"); + s.execute("appearance.addFill", &json!({"ids": [b.0, a.0]})).unwrap(); + assert_eq!(kept(&s), "#ff0000"); +} + +/// A label's object opacity and blend mode set without a row (`transparency.set` without `item`) are kept for its kind. +#[test] +fn a_labels_object_opacity_and_blend_mode_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // Another value axis number sets the kind's fill row; the label below has none, and the kind keeps the row. + let u = text_id(&s, id, "Axes", "2"); + s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [u.0], "index": 0, "color": "#0000ff"})).unwrap(); + let v = text_id(&s, id, "Axes", "4"); + s.execute("transparency.set", &json!({"ids": [v.0], "opacity": 50, "blend": "multiply"})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "0"), (vec![(true, "#0000ff".into(), 1.0, 0.0)], 0.5), "{looks:?}"); + assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Multiply); + assert_eq!(label_node(&s, id, "Axes", "Q1").blend, BlendMode::Normal); + // Another label of the kind sets them again. + let w = text_id(&s, id, "Axes", "2"); + s.execute("transparency.set", &json!({"ids": [w.0], "opacity": 50, "blend": "screen"})).unwrap(); + assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Multiply); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "0").1, 0.5, "{looks:?}"); + assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Screen); +} + +/// Stroke options given to a label's stroke row, a fill row painted as the Appearance panel's active row, and a row's +/// blend mode are kept for the label's kind. +#[test] +fn stroke_options_and_the_active_row_on_a_label_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("appearance.addStroke", &json!({"ids": [v.0]})).unwrap(); + s.execute("stroke.set", &json!({"ids": [v.0], "item": 0, "weight": 3})).unwrap(); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#00ffff"})).unwrap(); + s.execute("transparency.set", &json!({"item": 0, "blend": "multiply"})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "0").0.first().map(|r| r.3), Some(3.0), "{looks:?}"); + assert_eq!(rows_of(&looks, "Q1").0.first().map(|r| r.1.clone()), Some("#00ffff".into()), "{looks:?}"); + let q1 = label_node(&s, id, "Axes", "Q1"); + assert_eq!(q1.appearance.items.first().map(|it| it.blend()), Some(BlendMode::Multiply)); + let NodeKind::Text(t) = &q1.kind else { panic!("not type") }; + assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()), Some("#000000".into()), "the characters keep their fill"); +} + +/// A row below the Characters row stays below the characters when the graph is drawn again. +#[test] +fn a_kept_row_keeps_its_place_below_the_characters() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addStroke", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + // The stroke below the characters, the fill above them. + s.execute("appearance.moveItem", &json!({"ids": [c.0], "from": "contents", "to": 1})).unwrap(); + let look = |ap: &vectorcraft_doc::Appearance| (ap.contents_at(), ap.items.iter().map(|it| it.is_fill()).collect::>()); + assert_eq!(look(&label_node(&s, id, "Axes", "Q2").appearance), (1, vec![false, true])); + redrawn_label_rows(&mut s, id); + assert_eq!(look(&label_node(&s, id, "Axes", "Q1").appearance), (1, vec![false, true])); +} + +/// The Eyedropper keeps for a label's kind only the attributes it applied: a fill the label had of its own stays out. +#[test] +fn the_eyedropper_keeps_only_what_it_applied_for_a_labels_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap(); + let v = text_id(&s, id, "Axes", "4"); + // A fill of the label's own, as the generator gives labels on dark pie wedges. + s.edit("own fill", |d, _| { + if let NodeKind::Text(t) = &mut d.node_mut(v).unwrap().kind { + t.runs.iter_mut().for_each(|r| r.style.fill = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))); + } + Ok(()) + }) + .unwrap(); + // A pick of transparency alone leaves the kind's character style unset. + let transparency = json!({"character": false, "paragraph": false, "appearance": {"fill": false, "stroke": false}}); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0], "apply": transparency})).unwrap(); + assert_eq!(spec(&s, id).label_styles.values, None); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0], "apply": {"appearance": false}})).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, size, fill)| t == "0" && *size == 14.0 && fill == "#000000"), "{looks:?}"); +} + +/// Paint given to all of a label's text highlighted with the Type tool is kept for its kind, as for the whole label. +#[test] +fn paint_on_a_labels_highlighted_text_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.select_tool("type", crate::ViewInfo::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"id": v.0, "start": 0, "end": 1})); + s.execute("paint.setFill", &json!({"color": "#ff8800"})).unwrap(); + s.select_tool("selection", crate::ViewInfo::default()).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#ff8800"), "{looks:?}"); +} + +/// Paint given to the whole graph without a row is kept for the characters of every kind of label. +#[test] +fn paint_on_the_whole_graph_is_kept_for_every_kind_of_label() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff00ff"})).unwrap(); + let looks = label_looks(&mut s, id); + for t in ["0", "Q1", "2024"] { + assert!(looks.iter().any(|(x, _, fill)| x == t && fill == "#ff00ff"), "{t}: {looks:?}"); + } +} + +/// A gradient placed on one label's characters or on its row is kept without that placement, so it fits each label of +/// the kind when the graph is drawn again. +#[test] +fn a_placed_gradient_on_a_label_is_kept_to_fit_each_label() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let gradient = json!({"start": [30, 60], "end": [90, 60]}); + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setFill", &json!({"ids": [v.0], "gradient": gradient})).unwrap(); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [c.0], "item": 0, "gradient": gradient})).unwrap(); + let placed = |p: &Paint| matches!(p, Paint::Gradient(g) if g.geom.is_some()); + let g = spec(&s, id); + assert!(g.label_styles.values.is_some_and(|st| matches!(st.fill, Paint::Gradient(_)) && !placed(&st.fill))); + assert!(g.label_styles.categories_rows.and_then(|r| r.paint.fill).is_some_and(|f| matches!(f, Paint::Gradient(_)) && !placed(&f))); +} + +/// A graphic style applied to a label is kept with the label's rows for its kind. +#[test] +fn a_graphic_style_on_a_label_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#ff8000"})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": r.0, "name": "Orange"})).unwrap(); + let v = text_id(&s, id, "Axes", "4"); + s.execute("graphicStyle.apply", &json!({"name": "Orange", "ids": [v.0]})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#ff8000"), "{looks:?}"); + // A label still linked to the style follows a redefinition, and its kind keeps the new look. + let v = text_id(&s, id, "Axes", "4"); + s.execute("graphicStyle.apply", &json!({"name": "Orange", "ids": [v.0]})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#0080ff"})).unwrap(); + s.execute("graphicStyle.redefine", &json!({"name": "Orange", "id": r.0})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#0080ff"), "{looks:?}"); +} + #[test] fn pie_names_and_wedge_labels_keep_their_style() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/inline.rs b/crates/engine/src/cmd/inline.rs index 14264fc809..db3e929952 100644 --- a/crates/engine/src/cmd/inline.rs +++ b/crates/engine/src/cmd/inline.rs @@ -22,16 +22,14 @@ pub fn specs() -> Vec { } /// Inline graphics follow their symbols: after an edit of a document that has (or had) symbols, -/// resolve the inline art of every text again (only texts whose art changed are touched). +/// resolve the inline art of every text again, in the layers and in the art of symbols, graph +/// designs, pattern tiles and opacity masks, and lay out again the texts whose art changed (a +/// symbol's type before the type that shows that symbol inline). pub(crate) fn refresh(before: &Document, d: &mut Document) { if before.symbols.is_empty() && d.symbols.is_empty() { return; } - for id in d.resolve_inline_art() { - if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { - refresh_bounds(t); - } - } + d.resolve_inline_art_with(&mut refresh_bounds); } fn insert_inline(s: &mut Session, p: &Value) -> Result { @@ -85,7 +83,7 @@ fn insert_inline(s: &mut Session, p: &Value) -> Result { } let caret = s.edit("Insert Inline Symbol", |d, _| { let mut art = InlineArt { scale, baseline_shift: shift, ..InlineArt::new(symbol.clone()) }; - art.bounds = d.symbol_art_bounds(&symbol); + art.bounds = d.inline_art_bounds(&symbol); let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let style = edit::insertion_style(&t.runs, a, b); let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f589ea62da..83f27cdd61 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -994,20 +994,53 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result { ok() } -/// Delete artboards `indices` (sorted) of `d` with their guides; at least one must be left. -fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> { +/// Delete artboards `indices` (sorted, each once) of `d` with their guides; at least one must be +/// left. The other artboards keep their order, no later artboard takes a deleted one's id, and +/// `sel` forgets the deleted guides. One walk over the artboards and one over the ruler guides, +/// however many go. Delete Artboard, Delete Empty Artboards, Cut Artboard and Artwork Cleanup's +/// Empty Artboards. +pub(crate) fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> { + if indices.iter().any(|i| *i >= d.artboards.len()) { + return Err(EngineError::Other("no such artboard".into())); + } if indices.len() >= d.artboards.len() { return Err(EngineError::Other("a document needs at least one artboard".into())); } - // The last first, so the others keep their places; their guides go with them. - for &i in indices.iter().rev() { - let id = d.artboards.remove(i).id; - d.retire_artboard_id(id); - d.retain_guides(sel, |g| g.artboard != Some(id)); + if indices.is_empty() { + return Ok(()); } + let mut gone: HashSet = HashSet::with_capacity(indices.len()); + let mut i = 0; + d.artboards.retain(|a| { + let keep = indices.binary_search(&i).is_err(); + i += 1; + if !keep { + gone.insert(a.id); + } + keep + }); + count_artboard_pass(); + for &id in &gone { + d.retire_artboard_id(id); + } + d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a))); + count_artboard_pass(); Ok(()) } +#[cfg(test)] +thread_local! { + /// Tests: how many walks [`remove_artboards`] made over the artboards and the ruler guides on + /// this thread. + pub(crate) static ARTBOARD_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Tests: count one walk over the artboards or the ruler guides. +fn count_artboard_pass() { + #[cfg(test)] + ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1); +} + /// The artboards Delete Empty Artboards deletes: those with no artwork /// ([`Document::artboard_has_art`]) that aren't locked, but artboard 1 when every artboard is one /// of them (a document keeps one, as Delete Artboards keeps artboard 1). diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 21d6530e53..6ffdb04a5c 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -251,7 +251,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md)", + "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's opacity and blend mode for every label of its kind", has_doc, set_props ), diff --git a/crates/engine/src/cmd/object/props.rs b/crates/engine/src/cmd/object/props.rs index 1fb2faf769..bd00b6b323 100644 --- a/crates/engine/src/cmd/object/props.rs +++ b/crates/engine/src/cmd/object/props.rs @@ -65,10 +65,11 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { }) }; let d = &s.doc()?.doc; - // A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, so a value a mark - // already has still replaces the one another mark left on the graph. + // A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, and set on a label + // for its kind of label, so a value a mark or label already has still replaces the one another left on the graph. let transparency = opacity.is_some() || blend.is_some(); - let stores = || transparency && crate::cmd::graph::series_object_opacity_differs(d, &ids); + let stores = + || transparency && (crate::cmd::graph::series_object_opacity_differs(d, &ids) || crate::cmd::graph::label_object_opacity_differs(d, &ids)); if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) && !stores() { // Hidden or locked objects can't stay selected. if hides { @@ -124,6 +125,7 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { } if transparency { crate::cmd::graph::capture_series_object_opacity(d, &ids); + crate::cmd::graph::capture_label_object_opacity(d, &ids); } // Hidden or locked objects can't stay selected. if hides { diff --git a/crates/engine/src/cmd/object/shape.rs b/crates/engine/src/cmd/object/shape.rs index 2b32c59980..e3909c5935 100644 --- a/crates/engine/src/cmd/object/shape.rs +++ b/crates/engine/src/cmd/object/shape.rs @@ -135,9 +135,7 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { .map_err(|_| bad("object.setLiveShape", format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}"))) }) .transpose()?; - let radius = p.get("radius").and_then(Value::as_f64); let relative_radii = p.get("relativeRadii").and_then(Value::as_bool); - let sides = p.get("sides").and_then(Value::as_u64); // A number that must be `what` (None when absent). let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result> { match p.get(k) { @@ -151,6 +149,9 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { }; let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0))); let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0"); + let radius = number("radius", |r| r >= 0.0, "a radius of 0 or more")?; + // A polygon's side count: a whole number from 3 to 1000. + let sides = number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64); // An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them). let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?); let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 85d11ea7ec..c7159d563a 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Stroke", [], None, - "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern)", + "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, as paint.setFill", has_doc, |s, p| { set_paint(s, p, false) } ), @@ -64,16 +64,24 @@ pub fn specs() -> Vec { Ok(json!({ "fillActive": s.fill_active })) } ), - cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None → {objects}", has_doc, |s, _| { - let f = s.fill_active; - set_paint(s, &json!({"none": true}), f) - }), + cmd!( + "paint.none", + "None", + [], + Some("/"), + "{} set the active proxy (fill or stroke) to None, as paint.setFill or paint.setStroke with none does → {objects}", + has_doc, + |s, _| { + let f = s.fill_active; + set_paint(s, &json!({"none": true}), f) + } + ), cmd!( "transparency.set", "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", has_doc, transparency ), @@ -88,7 +96,7 @@ pub fn proxy_specs() -> Vec { "Invert", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md) → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert) ), @@ -467,6 +475,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let ids: Vec = ranges.iter().map(|(f, _, _)| *f).collect(); // When the type comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); + // The graph labels whose whole text takes the paint: a graph keeps it for their kind of label. + let whole: Vec = ranges + .iter() + .filter(|(id, a, b)| { + *a == 0 && matches!(before.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if *b >= vectorcraft_text::edit::runs_len(&t.runs)) + }) + .map(|(id, _, _)| *id) + .collect(); super::overprint::edit_counted(s, if fill { "Fill Color" } else { "Stroke Color" }, |d| { for &(id, a, b) in &ranges { let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; @@ -483,7 +499,17 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) }); super::typecmd::refresh_bounds(t); } - Ok(super::appearance::count_changed(d, &before, &ids)) + let labels = super::graph::capture_label_styles(d, &whole, &|st| { + if fill { + st.fill = unplaced(&paint); + } else { + if !paint.is_none() { + run_stroke_weight(st); + } + st.stroke = unplaced(&paint); + } + }); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(labels)) })?; if focus { s.fill_active = fill; @@ -523,6 +549,12 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) vec![] }; let label = if fill { "Fill Color" } else { "Stroke Color" }; + // Each object whose appearance took the paint, with the row it went to, for the paint a graph stores for its series + // (a series mark is a path, so it always has a row here). + let written = std::cell::RefCell::new(Vec::new()); + // The type objects whose characters took the paint (no row targeted): only those change what a graph's labels keep + // for their characters; a row edit is kept with the label's rows. + let characters = std::cell::RefCell::new(Vec::new()); edit_items_then( s, &ids, @@ -531,6 +563,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) label, fill, |n, index| { + if index.is_none() && matches!(n.kind, NodeKind::Text(_)) { + characters.borrow_mut().push(n.id); + } if index.is_none() && colorized.contains(&n.id) { n.extras_mut().colorize = paint.color().map(|_| Box::new(paint.clone())); return Ok(()); @@ -581,18 +616,23 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) if !fill { super::object::snap_if_aligned(n); } + let at = match index { + Some(_) => n.appearance.item_of_kind(index, fill), + None => n.appearance.items.iter().rposition(|it| it.is_fill() == fill), + }; + written.borrow_mut().extend(at.map(|at| (n.id, at))); Ok(()) }, - |d, changed| { - let series = super::graph::capture_series_paints(d, changed, fill); - let labels = super::graph::capture_label_styles(d, changed, &|st| { + |before, d, changed| { + let series = super::appearance::capture_series_paint_writes(before, d, changed, &written.borrow()); + let labels = super::graph::capture_label_styles(d, &characters.borrow(), &|st| { if fill { - st.fill = paint.clone(); + st.fill = unplaced(&paint); } else { if !paint.is_none() { run_stroke_weight(st); } - st.stroke = paint.clone(); + st.stroke = unplaced(&paint); } }); Ok(series || labels) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 5910435a75..f9c4b10049 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), 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), 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 ), @@ -345,15 +345,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result= d.artboards.len()) { - return Err(EngineError::Other("no such artboard".into())); - } - // The last first, so the others keep their places; their guides go with them. - for &i in indices.iter().rev() { - let gone = d.artboards.remove(i).id; - d.retire_artboard_id(gone); - d.retain_guides(sel, |g| g.artboard != Some(gone)); - } + super::layer::remove_artboards(d, sel, &indices)?; d.remove_many(&art)?; sel.prune(d); Ok(()) @@ -465,6 +457,23 @@ fn list_level_param(p: &Value, cmd: &str) -> Result> { } } +/// Set the Warichu and Warichu Settings of `st` that are given: Warichu on or off, Lines, Line Gap +/// (in the type's own units), Scale and Alignment. +fn set_warichu( + st: &mut vectorcraft_doc::CharStyle, + on: Option, + lines: Option, + gap: Option, + scale: Option, + align: Option, +) { + st.warichu = on.unwrap_or(st.warichu); + st.warichu_lines = lines.unwrap_or(st.warichu_lines); + st.warichu_gap = gap.unwrap_or(st.warichu_gap); + st.warichu_scale = scale.unwrap_or(st.warichu_scale); + st.warichu_align = align.unwrap_or(st.warichu_align); +} + fn set_format(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setFormat"; if p.get("paragraphIndents").is_some() { @@ -479,6 +488,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "allCaps", "proportionalMetrics", "tateChuYoko", + "warichu", "standardVerticalRoman", "noBreak", "kashidas", @@ -571,6 +581,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "tateChuYoko", "tcyUpDown", "tcyLeftRight", + "warichu", + "warichuLines", + "warichuGap", + "warichuScale", + "warichuAlign", "akiLeft", "akiRight", "standardVerticalRoman", @@ -617,6 +632,10 @@ 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 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)?; + let warichu_align = super::textedit::warichu_align_param(p, C)?; let mojikumi = match p.get("mojikumi") { None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { @@ -726,9 +745,13 @@ fn set_format(s: &mut Session, p: &Value) -> Result { // A paragraph whose Minimum, Desired and Maximum would no longer be in order. let mut out_of_order: Option<&str> = None; let range = super::typecmd::TextRange::parse(p, C)?; - // The character attributes, given the baseline shift, horizontal scale and Tate-chu-yoko - // offsets in the type's own units. - let chars = |st: &mut vectorcraft_doc::CharStyle, baseline_shift: Option, h_scale: Option, tcy: (Option, Option)| { + // The character attributes, given the baseline shift, horizontal scale, Tate-chu-yoko offsets + // and warichu Line Gap in the type's own units. + let chars = |st: &mut vectorcraft_doc::CharStyle, + baseline_shift: Option, + h_scale: Option, + tcy: (Option, Option), + gap: Option| { if let Some(k) = kerning { st.kerning = k; } @@ -786,6 +809,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = tcy.1 { st.tcy_left_right = v; } + set_warichu(st, flag("warichu"), warichu_lines, gap, warichu_scale, warichu_align); if let Some(v) = aki_left { st.aki_left = v; } @@ -851,7 +875,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?; let local = |v: Option| v.map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose(); let tcy = (local(tcy_up_down)?, local(tcy_left_right)?); - super::typecmd::style_chars(t, range, |st| chars(st, baseline_shift, h_scale, tcy)); + let gap = + warichu_gap.map(|v| super::typecmd::local_type_value(t, v, (0.0, vectorcraft_doc::text::MAX_WARICHU_GAP), false, C)).transpose()?; + super::typecmd::style_chars(t, range, |st| chars(st, baseline_shift, h_scale, tcy, gap)); let span = super::typecmd::para_span(range, t); let paras = |para: &mut vectorcraft_doc::ParaStyle, out_of_order: &mut Option<&'static str>| { if let Some(v) = num("leftIndent") { @@ -960,7 +986,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if range.is_none() { let shift = num("baselineShift").map(|v| v.clamp(-1296.0, 1296.0)); let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0)); - changed |= super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); + changed |= super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right), warichu_gap)); if let Some(paras) = para_change { // The kept attributes are checked too: they may differ from the labels'. changed |= super::graph::capture_label_paragraphs(d, &ids, &|para| { diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index ddbaec22a2..20e5054aeb 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -8,7 +8,9 @@ //! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't //! described key by key, so only the top level is checked, except in the options of an export, a //! save or Export for Screens: those are checked against the options of the formats the command -//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]). +//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]); and in the +//! objects of an array whose keys `MEMBER_KEYS` lists, such as the `items` of +//! `object.setLiveShape`. //! //! [`Session::execute`] answers a key the description doesn't name with a parameter error //! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the @@ -172,6 +174,13 @@ fn accepted(doc: &'static str, depth: usize) -> Option> { Some(keys) } +/// Every key the description `doc` takes, those of the commands its `…` entries name included, or +/// `None` when that isn't known: it doesn't parse, a `…` entry names no command, or one names a +/// command in [`UNCHECKED`]. +pub(crate) fn accepted_keys(doc: &'static str) -> Option> { + accepted(doc, 0) +} + /// Does the description `doc` name `key` as a parameter (its own keys, and those of the commands /// its `…` entries name)? pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool { @@ -262,6 +271,41 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError ) } +/// Commands whose params hold an array of objects with keys of their own: the command, the key of +/// the array and the keys each of its objects takes. +const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[("object.setLiveShape", "items", &["id", "corners"])]; + +/// Each object in the arrays [`MEMBER_KEYS`] names for command `id` has only the keys it lists, or +/// the parameter error naming the first other one with its place (`items[0].corner`), and the +/// nearest accepted key or the accepted ones. A member that isn't an object is the command's to +/// report. +fn check_member_keys(id: &str, params: &Value) -> Result<()> { + for (_, list, keys) in MEMBER_KEYS.iter().filter(|(c, ..)| *c == id) { + let Some(members) = params.get(*list).and_then(Value::as_array) else { continue }; + for (i, member) in members.iter().enumerate() { + let Value::Object(given) = member else { continue }; + let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) else { continue }; + let at = |k: &str| format!("{list}[{i}].{k}"); + let near = nearest(key, keys); + let key = at(&shown_key(key)); + return Err(bad( + id, + match near { + Some(near) => { + let near = at(near); + format!("unknown parameter `{key}`; did you mean `{near}`?") + } + None => { + let keys = keys.iter().map(|k| at(k)).collect::>().join(", "); + format!("unknown parameter `{key}`; the command takes: {keys}") + } + }, + )); + } + } + Ok(()) +} + /// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the /// effect takes (those of its dialog defaults and of its params description), each of the type /// the effect reads ([`check_effect_types`]), a `color` the effect can draw ([`check_effect_color`]) @@ -519,7 +563,6 @@ pub const UNCHECKED: &[&str] = &[ "object.rasterize", "object.repeat.options", "object.setBounds", - "object.setLiveShape", "object.setOverprint", "object.setProps", "object.slice.divide", @@ -826,6 +869,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> { return Ok(()); } check_keys(id, spec.params, params)?; + check_member_keys(id, params)?; if in_batch { crate::schemas::check(spec, params, false)?; } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 2dee458930..33bde936bf 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -314,6 +314,15 @@ fn set_handle(s: &mut Session, p: &Value) -> Result { 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")?; + // A handle already there, on an anchor already of the kind it would get, changes nothing and + // records no undo step. + if let Some(a) = anchor_of(s, id, si, ai)? { + let mut b = a; + b.set_handle(out, pos, independent); + if b == a { + return ok(); + } + } s.edit("Reshape", |d, _| { let path = path_mut(d, id)?; let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; @@ -330,6 +339,12 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result { 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 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)? + && a.transform(vectorcraft_geom::Affine::translate(pos - a.p)) == a + { + return ok(); + } s.edit("Move", |d, _| { let path = path_mut(d, id)?; let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; @@ -345,6 +360,16 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result { ok() } +/// Anchor `ai` of subpath `si` of path `id` in the active document, when `id` is a path that has it. +/// Busy while a script runs, as [`Session::edit`] is. +fn anchor_of(s: &Session, id: NodeId, si: usize, ai: usize) -> Result> { + if s.script_running() { + return Err(EngineError::Busy); + } + let d = &s.doc()?.doc; + Ok(d.node(id).and_then(|n| n.path_data()).and_then(|path| path.subpaths.get(si)).and_then(|sp| sp.anchors.get(ai)).copied()) +} + fn set_anchors(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?; let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?; diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index b4ce273dad..0085c2d9d6 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -803,35 +803,59 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { let miter = number_given(p, "miterLimit", C)?.unwrap_or(4.0).clamp(1.0, 500.0); let roots = selected_roots(s)?; let ids = s.edit("Offset Path", |d, sel| { - let (mut out, mut above) = (vec![], vec![]); - let mut paths = false; + // Each selected root, whether it is a group, and the style of each of its leaf paths; the + // paths themselves go to `sources` in the same order. + let (mut roots_found, mut sources) = (vec![], vec![]); for id in &roots { - let Some(n) = d.node(*id).cloned() else { continue }; - let mut lv = vec![]; - leaves(&n, &mut lv); + let Some(n) = d.node(*id) else { continue }; + let mut styles = vec![]; + each_leaf(n, &mut |l| { + if !matches!(l.kind, NodeKind::Text(_)) + && let Some((path, rule)) = node_path(l) + { + styles.push(Style::of(l)); + sources.push((path, rule, matches!(l.kind, NodeKind::Compound { .. }))); + } + }); + roots_found.push((*id, matches!(n.kind, NodeKind::Group { .. }), styles)); + } + let paths = !sources.is_empty(); + let offset = |(path, rule, compound): &(PathData, FillRule, bool)| { + let normalized; + let base = if *compound && path.is_closed() { + normalized = po::normalize(path, *rule); + &normalized + } else { + path + }; + match po::try_offset_path(base, delta, join, miter) { + Ok(p) => Ok(Some(p)), + Err(po::PathOpsError::NonFinite) => Ok(None), + Err(e) => Err(e), + } + }; + // The offsets are worked out together; the nodes are made here, in selection order, so + // the ids, the stacking and the first error are those of one path after another. + let (offsets, _) = ordered_map(&sources, offset_threads(), offset, std::result::Result::is_err); + let mut offsets = offsets.into_iter(); + let (mut out, mut above) = (vec![], vec![]); + for (id, group, styles) in roots_found { let mut made = vec![]; - for l in lv.iter().filter(|l| !matches!(l.kind, NodeKind::Text(_))) { - let Some((path, rule)) = node_path(l) else { continue }; - paths = true; - let base = if matches!(l.kind, NodeKind::Compound { .. }) && path.is_closed() { po::normalize(&path, rule) } else { path }; - let off = match po::try_offset_path(&base, delta, join, miter) { - Ok(p) => p, - Err(po::PathOpsError::NonFinite) => continue, - Err(po::PathOpsError::TooComplex) => { + for style in &styles { + match offsets.next().flatten() { + Some(Ok(Some(off))) if !off.is_empty() => made.push(shape_node_styled(d, off, style)), + Some(Err(po::PathOpsError::TooComplex)) => { return Err(EngineError::Other("Offset Path: the path is too complex to offset".into())); } - Err(e) => return Err(EngineError::Other(format!("Offset Path: {e}"))), - }; - if off.is_empty() { - continue; + Some(Err(e)) => return Err(EngineError::Other(format!("Offset Path: {e}"))), + _ => {} } - made.push(shape_node(d, off, Some(l))); } if made.is_empty() { continue; } let new = if made.len() == 1 - && !matches!(n.kind, NodeKind::Group { .. }) + && !group && let Some(one) = made.pop() { one @@ -840,7 +864,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { Node::group(gid, made.into_iter().map(Arc::new).collect()) }; out.push(new.id); - above.push((*id, vec![new])); + above.push((id, vec![new])); } if out.is_empty() && paths { return Err(EngineError::Other("Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes)".into())); @@ -855,6 +879,86 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { Ok(ids_json(&ids)) } +/// The threads [`offset_path`] works on: the logical CPUs, or one on the web. +fn offset_threads() -> usize { + #[cfg(target_arch = "wasm32")] + { + 1 + } + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::available_parallelism().map_or(1, std::num::NonZero::get) + } +} + +/// `f` of each of `items`, in their order. The items are split into runs whose sizes differ by +/// at most one, as many as `threads` or the items allow and at least one. This thread works +/// on the first run, and a thread of its own works on each other run (this thread does when that +/// one can't start). Once `stop` accepts a result, the items after it are left out (`None`); every +/// item before the first such result has its result. Returns the results and the number of +/// threads that worked on them. +pub(crate) fn ordered_map( + items: &[T], + threads: usize, + f: impl Fn(&T) -> R + Sync, + stop: impl Fn(&R) -> bool + Sync, +) -> (Vec>, usize) { + use std::sync::atomic::{AtomicUsize, Ordering}; + let mut out: Vec> = std::iter::repeat_with(|| None).take(items.len()).collect(); + let stopped = AtomicUsize::new(usize::MAX); + let run = |from: usize, its: &[T], out: &mut [Option]| { + for (i, (item, slot)) in its.iter().zip(out).enumerate() { + let at = from + i; + if at > stopped.load(Ordering::Relaxed) { + return; + } + let r = f(item); + if stop(&r) { + stopped.fetch_min(at, Ordering::Relaxed); + } + *slot = Some(r); + } + }; + let runs = threads.clamp(1, items.len().max(1)); + if runs == 1 { + run(0, items, &mut out); + return (out, 1); + } + // Run `k` holds the items from `start(k)` up to `start(k + 1)`. + let start = |k: usize| k * items.len() / runs; + let mut unstarted = vec![]; + let used = std::thread::scope(|s| { + let mut rest = out.as_mut_slice(); + let mut first = None; + let mut used = 1; + for k in 0..runs { + let (from, to) = (start(k), start(k + 1)); + let Some((o, tail)) = std::mem::take(&mut rest).split_at_mut_checked(to - from) else { break }; + rest = tail; + let its = items.get(from..to).unwrap_or_default(); + if k == 0 { + first = Some((o, its)); + continue; + } + let run = &run; + match std::thread::Builder::new().spawn_scoped(s, move || run(from, its, o)) { + Ok(_) => used += 1, + Err(_) => unstarted.push(k), + } + } + if let Some((o, its)) = first { + run(0, its, o); + } + used + }); + for (from, to) in unstarted.into_iter().map(|k| (start(k), start(k + 1))) { + if let (Some(o), Some(its)) = (out.get_mut(from..to), items.get(from..to)) { + run(from, its, o); + } + } + (out, used) +} + // ---------- Outline Stroke ---------- /// Can Outline Stroke turn `st` into filled art? diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs index 71fbcc8540..d42cc5b82c 100644 --- a/crates/engine/src/cmd/plugin_call.rs +++ b/crates/engine/src/cmd/plugin_call.rs @@ -150,28 +150,47 @@ pub fn location_keys(spec: &CommandSpec) -> Vec<(&'static str, Location)> { keys } -/// Commands and the param key in [`FILE_KEYS`] that, in them, names part of the document (a path of -/// its art, a setting, an attribute it stores or a result field), not a file. +/// Commands and the param of theirs, a key in [`FILE_KEYS`], that names part of the document (a path +/// of its art, a setting or an attribute it stores), not a file. Each names a param the command +/// takes ([`takes_param`]). pub(crate) const NOT_FILES: &[(&str, &str)] = &[ - ("object.setLiveShape", "path"), - ("path.reverse", "paths"), - ("object.pathfinder.options", "paths"), - ("object.maskImage", "path"), ("text.createInPath", "path"), - ("imageTrace.make", "paths"), ("symbol.instance.setAppearance", "path"), ("symbol.instance.selectPart", "path"), ("object.objectsOnPath.attach", "path"), ("attributes.set", "url"), - ("svgInteractivity.info", "files"), - ("addon.cleanup.rejoin", "paths"), ("object.slice.options", "url"), ("document.exportForScreens", "folders"), ]; /// Commands and the keys from the top of their params down to a key in [`FILE_KEYS`] that, there, -/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`. -const NESTED_NOT_FILES: &[(&str, &[&str])] = &[("stroke.set", &["startArrow", "Custom", "path"]), ("stroke.set", &["endArrow", "Custom", "path"])]; +/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`, and the +/// path fidelity setting (0 to 100) of Image Trace's tracing settings. The first key of each is a +/// param the command takes ([`takes_param`]). +const NESTED_NOT_FILES: &[(&str, &[&str])] = &[ + ("stroke.set", &["startArrow", "Custom", "path"]), + ("stroke.set", &["endArrow", "Custom", "path"]), + ("imageTrace.make", &["params", "paths"]), + ("imageTrace.make", &["savedPreset", "paths"]), + ("imageTrace.makeAndExpand", &["params", "paths"]), + ("imageTrace.makeAndExpand", &["savedPreset", "paths"]), +]; + +/// Does `spec` take `key` as a param: its params description names it as one of its keys, or one +/// of the commands its `…` entries name does ([`super::params::accepted_keys`]), or its schema has +/// it? When the description doesn't say which keys it takes, any `key:` in its text counts +/// ([`names_key`]). A word in its prose, in a nested object or in its result isn't a param. +fn takes_param(spec: &CommandSpec, key: &str) -> bool { + spec.schema.is_some_and(|s| s.params.iter().any(|p| p.name == key)) || described_param(spec.params, key) +} + +/// [`takes_param`] for the params description `doc` alone. +fn described_param(doc: &'static str, key: &str) -> bool { + match super::params::accepted_keys(doc) { + Some(keys) => keys.contains(&key), + None => names_key(doc, key) || super::params::takes_key(doc, key), + } +} /// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`], /// [`FILE_WORDS`])? @@ -195,16 +214,14 @@ pub(crate) fn denied(spec: &CommandSpec) -> Option<&'static str> { None } -/// Why a plug-in may never call `spec` from its id ([`denied_id`]) or its params (a key in -/// [`FILE_KEYS`] that [`NOT_FILES`] doesn't name for it), or `None`. +/// Why a plug-in may never call `spec` from its id ([`denied_id`]) or its params (a param in +/// [`FILE_KEYS`], [`takes_param`], that [`NOT_FILES`] doesn't name for it), or `None`. fn denied_by_rule(spec: &CommandSpec) -> Option<&'static str> { let id = spec.id; if denied_id(id) { return Some("id"); } - let mut keys = FILE_KEYS.iter().filter(|k| !NOT_FILES.contains(&(id, **k))); - let in_schema = |k: &str| spec.schema.is_some_and(|s| s.params.iter().any(|p| p.name == k)); - if keys.any(|k| names_key(spec.params, k) || in_schema(k)) { + if FILE_KEYS.iter().any(|k| !NOT_FILES.contains(&(id, *k)) && takes_param(spec, k)) { return Some("file key"); } None @@ -498,9 +515,10 @@ impl Saved { mod tests { use super::*; - /// Every command in the registry is sorted: the ones a plug-in may call name no file, folder, - /// clipboard, preference or script in their params text, and the known file, document, script - /// and history commands are refused. + /// Every command in the registry is sorted: the ones a plug-in may call take no file, folder, + /// clipboard, preference or script param, and the known file, document, script and history + /// commands are refused. Each [`NOT_FILES`] key, and the first key of each [`NESTED_NOT_FILES`] + /// list, is a param its command takes, not a word in its description's prose or result. #[test] fn every_command_is_sorted() { let specs = crate::cmd::command_specs(); @@ -521,18 +539,43 @@ mod tests { "edit.copy", "edit.paste", "prefs.set", + "stroke.arrowheads.list", ] { let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; assert!(denied(spec).is_some(), "{id} is allowed"); } - for id in ["select.all", "object.transform", "path.reverse", "symbol.instance.selectPart"] { + for id in [ + "select.all", + "object.transform", + "path.reverse", + "symbol.instance.selectPart", + "object.setLiveShape", + "object.maskImage", + "imageTrace.make", + "svgInteractivity.info", + "addon.cleanup.rejoin", + ] { let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; assert_eq!(denied(spec), None, "{id} is refused"); } - for (id, key) in NOT_FILES { + let Some(options) = crate::cmd::find_command("object.pathfinder.options") else { panic!("no command object.pathfinder.options") }; + assert_eq!(denied(options), Some("reviewed")); + let real = |id: &str, key: &str| { let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; - let described = crate::cmd::params::doc_keys(spec.params).is_ok_and(|p| p.keys.contains(key)); - assert!(names_key(spec.params, key) || described, "{id} has no `{key}` param"); + let in_schema = spec.schema.is_some_and(|s| s.params.iter().any(|p| p.name == key)); + let Some(described) = crate::cmd::params::accepted_keys(spec.params) else { + panic!("{id}: its params description doesn't say which keys it takes") + }; + in_schema || described.contains(&key) + }; + for (id, key) in NOT_FILES { + assert!(FILE_KEYS.contains(key), "{id} `{key}` isn't a file key"); + assert!(real(id, key), "{id} has no `{key}` param"); + } + for (id, keys) in NESTED_NOT_FILES { + let (Some(first), Some(last)) = (keys.first(), keys.last()) else { panic!("{id}: an empty key list") }; + assert!(FILE_KEYS.contains(last), "{id} {keys:?} doesn't end at a file key"); + assert!(real(id, first), "{id} has no `{first}` param"); } let leaks: Vec<&str> = specs .iter() @@ -540,7 +583,7 @@ mod tests { .filter(|c| { let lower = c.id.to_ascii_lowercase(); FILE_WORDS.iter().any(|w| lower.contains(w)) - || FILE_KEYS.iter().any(|k| names_key(c.params, k) && !NOT_FILES.contains(&(c.id, *k))) + || FILE_KEYS.iter().any(|k| takes_param(c, k) && !NOT_FILES.contains(&(c.id, *k))) || names_key(c.params, "sourceDocument") }) .map(|c| c.id) @@ -598,10 +641,23 @@ mod tests { assert!(names_file("select.all", &json!([[[{"url": "file:///etc/hosts"}]]]))); assert!(!names_file("effect.apply", &json!({"effect": "distort.glass", "params": {"distortion": 5, "textureImage": "frosted"}}))); assert!(!names_file("object.move", &json!({"dx": 5, "dy": 0, "ids": [1, 2], "opts": {"copy": false}}))); - // `path` is a shape's path in object.setLiveShape, but only as its own param. - assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]}))); - assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}}))); + // `path` is the path text is set on in text.createInPath, but only as its own param. + assert!(!names_file("text.createInPath", &json!({"path": 5, "text": "a"}))); + assert!(names_file("text.createInPath", &json!({"options": {"path": "/tmp/x"}}))); assert!(names_file("object.move", &json!({"path": [[0, 0]]}))); + // object.setLiveShape takes no `path`; its description says "on any path:" in its prose. + assert!(names_file("object.setLiveShape", &json!({"path": "/tmp/x"}))); + // Image Trace's path fidelity setting, only inside its tracing settings. + for id in ["imageTrace.make", "imageTrace.makeAndExpand"] { + assert!(!names_file(id, &json!({"preset": "Default", "params": {"mode": "color", "paths": 50}}))); + assert!(!names_file(id, &json!({"savedPreset": {"paths": 50, "corners": 75}}))); + assert!(names_file(id, &json!({"paths": 50}))); + assert!(names_file(id, &json!({"params": {"paths": 50, "path": "/tmp/x"}}))); + assert!(names_file(id, &json!({"params": {"file": "/tmp/x"}}))); + assert!(names_file(id, &json!({"params": {"paths": {"file": "/tmp/x"}}}))); + assert!(names_file(id, &json!({"options": {"paths": 50}}))); + } + assert!(names_file("object.move", &json!({"params": {"paths": 50}}))); // A custom arrowhead's outline in stroke.set, only where stroke.set takes one. let outline = json!({"subpaths": []}); assert!(!names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline}}}))); @@ -675,6 +731,39 @@ mod tests { assert_eq!(key_location("pathData"), None); } + /// A file key counts as a command's param only where its description takes it: not a word in + /// the prose after the params, in a nested object or in the result. A description that doesn't + /// say which keys it takes counts any `key:` in its text. + #[test] + fn a_param_is_a_key_the_description_takes() { + assert!(described_param("{path: id, text?: string}", "path")); + assert!(described_param("{id?|ids?, path?: id}", "path")); + assert!(!described_param("{id?, radius?: pt} rounds the corners on any path: …", "path")); + assert!(!described_param("{ids?: [id]} reverses the paths: their direction", "paths")); + assert!(!described_param("{} → {id, path: the mask's path}", "path")); + assert!(!described_param("{params?: {paths: 0-100}}", "paths")); + // The keys of the command a `…` entry names. + assert!(described_param("{…as links.relink}", "folder")); + assert!(!described_param("{…as links.relink}", "url")); + // No parameter object, or a `…` entry that names no command: any `key:` counts. + assert!(described_param("path: a file", "path")); + assert!(described_param("{…more settings, id?} saves to path: a file", "path")); + assert!(!described_param("{…more settings, id?} reverses the subpath: x", "path")); + for (id, key) in [ + ("object.setLiveShape", "path"), + ("path.reverse", "paths"), + ("object.pathfinder.options", "paths"), + ("object.maskImage", "path"), + ("imageTrace.make", "paths"), + ("svgInteractivity.info", "files"), + ("addon.cleanup.rejoin", "paths"), + ] { + let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; + assert!(!takes_param(spec, key), "{id} takes `{key}`"); + assert!(!NOT_FILES.contains(&(id, key)), "{id} `{key}` is exempt"); + } + } + #[test] fn keys_are_found_as_whole_keys() { assert!(names_key("{path: a .wasm file}", "path")); diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 21d9d65c3d..5329f23331 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.strokeToFill", "addon.color.swatchLegend", "addon.contour.set", + "addon.cutting.banner", "addon.cutting.bridges", "addon.cutting.weed", "addon.image.applyColorMask", @@ -58,6 +59,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.pathtools.shape.set", "addon.pathtools.slideAnchor", "addon.pathtools.smooth", + "addon.pathtools.trim", "addon.pen.append", "addon.pen.close", "addon.pen.closeJoin", @@ -158,6 +160,7 @@ pub(super) const ALLOWED: &[&str] = &[ "edit.colors.saturate", "edit.colors.toCMYK", "edit.colors.toGrayscale", + "edit.colors.toIndexed", "edit.colors.toRGB", "edit.duplicate", "edit.findNext", @@ -166,6 +169,7 @@ pub(super) const ALLOWED: &[&str] = &[ "effect.deferredStatus", "effect.duplicate", "effect.expandAppearance", + "effect.expandAsWireframes", "effect.list", "effect.move", "effect.remove", @@ -690,8 +694,8 @@ pub(super) const ALLOWED: &[&str] = &[ ]; /// Commands a plug-in may not call although no rule refuses them, in order: they change the -/// preferences, a tool's options, a drag's state or the font names the whole app knows, or run -/// plug-ins again. +/// preferences, a tool's options, a drag's state or the font names the whole app knows, run +/// plug-ins again, or read a file and report where it is on disk. pub(super) const DENIED: &[&str] = &[ "addon.cleanup.splitText", "addon.pathtools.movePointsTogether", @@ -703,5 +707,6 @@ pub(super) const DENIED: &[&str] = &[ "object.liquify", "object.pathfinder.options", "stroke.arrowheads.edit", + "stroke.arrowheads.list", "type.compositeFonts.set", ]; diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 748e0a125a..d4ad54932e 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -368,6 +368,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool), p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)), p!("linuxDisplayServer", "Performance", "", "Linux Display Server", choice(LINUX_DISPLAY_SERVERS)), + p!("monitorProfileDisplay", "Performance", "", "Use Monitor Profile for Canvas", bool), // File Handling p!("backgroundSave", "File Handling", "", "Enable Background Save", bool), p!("backgroundExport", "File Handling", "", "Enable Background Export", bool), diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 8194f85547..db0c0ac367 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -15,7 +15,7 @@ use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::colorcmds::{NotChanged, Recolor, Scope, left_out}; +use super::colorcmds::{NotChanged, Recolor, Scope, keeping_series_paint, left_out}; use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param}; pub fn specs() -> Vec { @@ -34,7 +34,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", has_doc, apply ), @@ -1707,7 +1707,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { .filter_map(|(id, hidden)| Some((id, shown_colors(d, d.node(id)?), hidden))) .collect(); map.converted.set(false); - let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); + let changed = keeping_series_paint(d, &ids, |d| Ok(Recolor::with_links(scope, &f, &lf).run(d, &ids)))?; let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?; // Rewriting the group's swatches can change the colors of art linked to them, left out or diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index c1c7f789fe..de4d145840 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -576,13 +576,25 @@ pub fn store(s: &Session) -> Option> { } #[cfg(not(target_arch = "wasm32"))] { - let folder = Some(s.prefs.recovery_folder.trim()).filter(|f| !f.is_empty()).map(str::to_string).or_else(|| s.recovery.default_folder.clone()); - folder.map(|f| Arc::new(FolderStore::new(f)) as Arc) + folder(s).map(|(f, _)| Arc::new(FolderStore::new(f)) as Arc) } #[cfg(target_arch = "wasm32")] None } +/// The folder copies go to now, and whether the `recoveryFolder` preference names it (else it is +/// the folder the app sets). `None` when they go to a store the host installed (browser storage) +/// or nowhere. +pub fn folder(s: &Session) -> Option<(String, bool)> { + if s.recovery.store.is_some() || cfg!(target_arch = "wasm32") { + return None; + } + match s.prefs.recovery_folder.trim() { + "" => s.recovery.default_folder.clone().map(|f| (f, false)), + set => Some((set.to_string(), true)), + } +} + fn has_store(s: &Session) -> std::result::Result<(), String> { match store(s) { Some(_) => Ok(()), diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index fe846ad8af..c5e5ba2a5c 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Stroke Weight Up", [], Some("Cmd+Alt+."), - "{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn → {objects}", + "{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke (docs/mcp.md) → {objects}", has_doc, |s, p| stroke_step(s, p, true) ), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 65fe18b282..1c41006b3f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md); 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. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -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) → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); 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 ), @@ -374,6 +374,8 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { super::appearance::clear_stood_for(d, &groups); super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true); } + // A label keeps its new rows for its kind of label. + super::graph::capture_label_rows(d, &ids); Ok(()) })?; ok() @@ -506,6 +508,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { apply_style(d, *nid, &g, &mut shared, chars); } 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)); Ok(()) })?; diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 6fc8b12edc..0fd7c7d97d 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), 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), 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 ), @@ -459,6 +459,12 @@ pub(crate) struct CharChange { /// Tate-chu-yoko Settings › Up/Down and Left/Right, in points. tcy_up_down: Option, tcy_left_right: Option, + warichu: Option, + /// Warichu Settings › Lines, Line Gap (points), Scale (percent) and Alignment. + warichu_lines: Option, + warichu_gap: Option, + warichu_scale: Option, + warichu_align: Option, /// Insert Aki Left and Right, in 1/1000 em. aki_left: Option, aki_right: Option, @@ -495,6 +501,41 @@ pub(crate) fn tcy_offset_param(p: &Value, key: &str, cmd: &str) -> Result