diff --git a/Cargo.lock b/Cargo.lock index 734ccc628d..926fea946c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6214,6 +6214,7 @@ dependencies = [ "image", "jpeg-encoder", "log", + "png 0.18.1", "proptest", "psd", "serde_json", diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index 3827ab1eb1..82f0b100d0 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -32,10 +32,11 @@ log = { workspace = true } [target.'cfg(windows)'.dependencies] winsafe = { version = "0.0.29", default-features = false, features = ["kernel", "psapi"] } -# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that -# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0. +# DirectWrite's system font collection and GDI's font list (the desktop app's src/system_fonts.rs, +# shared): fonts that font services such as Adobe Fonts load in place (#579, #1258). MIT OR +# Apache-2.0. [target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] } +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] } # CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font # managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT. diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 0b7883f311..561fd0e845 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -50,13 +50,14 @@ clipboard-win = { workspace = true } # Win32 (MIT); the `user` feature is User32.dll only. Only the wgpu renderer chooses an adapter. winsafe = { version = "0.0.29", default-features = false, features = ["user"], optional = true } -# DirectWrite's system font collection (src/system_fonts.rs): fonts that font services such as -# Adobe Fonts load in place (#579). MIT OR Apache-2.0, already in the build through accesskit. +# DirectWrite's system font collection and GDI's font list (src/system_fonts.rs): fonts that font +# services such as Adobe Fonts load in place (#579, #1258). MIT OR Apache-2.0, already in the build +# through accesskit. # Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to # read a pen's barrel button from its pointer messages. Win32_UI_Input_Pointer has # `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down. [target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } [target.'cfg(target_os = "macos")'.dependencies] # The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 78b4302ab6..ee4232dc32 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -1070,13 +1070,15 @@ fn main() -> std::process::ExitCode { } // Linux may not map the window before the first frames, so a saved maximize is retried then (#3635). let pending_maximize = cfg!(target_os = "linux") && app.ui.window.as_ref().is_some_and(|g| g.maximized); - // User Defined swatch, graphic style and symbol libraries live next to the preferences. + // User Defined swatch, graphic style, symbol and brush libraries live next to the preferences. let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string())); app.session.swatch_libraries.set_user_dir(swatches); let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string())); app.session.style_libraries.set_user_dir(styles); let symbols = prefs_path().and_then(|p| Some(p.parent()?.join("Symbols").to_string_lossy().to_string())); app.session.symbol_libraries.set_user_dir(symbols); + let brushes = prefs_path().and_then(|p| Some(p.parent()?.join("Brushes").to_string_lossy().to_string())); + app.session.brush_libraries.set_user_dir(brushes); // With the User Defined libraries listed, the persistent library panels reopen. vectorcraft_ui_egui::panels::library_panel::restore(&mut app); // So do the Libraries panel's libraries; runs without preferences (agents' test diff --git a/apps/vectorcraft/src/system_fonts.rs b/apps/vectorcraft/src/system_fonts.rs index 6752aef676..67bbf7843a 100644 --- a/apps/vectorcraft/src/system_fonts.rs +++ b/apps/vectorcraft/src/system_fonts.rs @@ -1,39 +1,56 @@ -//! Windows: the font files in DirectWrite's system font collection, scanned with the font folders -//! (`vectorcraft_text::set_platform_font_files`). The desktop app and `vectorcraft-cli` (exports, -//! MCP) both install it; the CLI shares this file. +//! Windows: the font files in DirectWrite's system font collection and those of the fonts GDI has +//! loaded, scanned with the font folders (`vectorcraft_text::set_platform_font_files`). The desktop +//! app and `vectorcraft-cli` (exports, MCP) both install it; the CLI shares this file. //! //! Font services such as Adobe Fonts load their fonts with GDI at run time, from files in their //! own folders, without installing them: the font folders and the registry don't list them. Since -//! Windows 10 (version 1607) DirectWrite's system font collection holds them too +//! Windows 10 (version 1607) DirectWrite's system font collection is meant to hold them too //! (, //! "Support for Adobe Typekit and other font-service clients"), so VectorCraft lists them as other -//! apps do, reading the files in place (#579). +//! apps do, reading the files in place (#579). It doesn't always: on some machines the fonts +//! Creative Cloud loads are in GDI's font list (Word and the font dialogs show them) but not in +//! the collection (#1258). So the families GDI lists that the collection doesn't name are asked +//! for one by one: a GDI font selected into a device context gives the DirectWrite font face +//! GDI uses (`IDWriteGdiInterop::CreateFontFaceFromHdc`), and that face its files. //! -//! DirectWrite is COM, which has no safe API, hence the scoped `unsafe_code` allowance. +//! DirectWrite is COM and GDI a C API, neither with a safe API, hence the scoped `unsafe_code` +//! allowance. #![allow(unsafe_code)] -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Mutex; +use windows::Win32::Foundation::LPARAM; use windows::Win32::Graphics::DirectWrite::{ - DWRITE_FACTORY_TYPE_SHARED, DWriteCreateFactory, IDWriteFactory3, IDWriteFontSet, IDWriteLocalFontFileLoader, + DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_PROPERTY_ID_WIN32_FAMILY_NAME, DWriteCreateFactory, IDWriteFactory3, IDWriteFontFile, IDWriteFontSet, + IDWriteGdiInterop, IDWriteLocalFontFileLoader, IDWriteStringList, +}; +use windows::Win32::Graphics::Gdi::{ + CreateCompatibleDC, CreateFontIndirectW, DEFAULT_CHARSET, DeleteDC, DeleteObject, EnumFontFamiliesExW, HDC, HGDIOBJ, LOGFONTW, RASTER_FONTTYPE, + SelectObject, TEXTMETRICW, }; use windows::core::Interface; /// The most fonts read from the collection (a system has a few thousand). const MAX_FONTS: u32 = 1 << 16; +/// The most families and faces taken from GDI's font list. +const MAX_GDI_FONTS: usize = 1 << 16; +/// The most files one font face is made of (one, but for a few Type 1 fonts). +const MAX_FACE_FILES: u32 = 8; /// The longest file path taken, in UTF-16 units (Windows' own limit). const MAX_PATH: u32 = 32_767; -/// Have the font scans read the font files DirectWrite lists. +/// Have the font scans read the font files DirectWrite and GDI list. pub fn install() { vectorcraft_text::set_platform_font_files(font_files); } /// The local files of the fonts in DirectWrite's system font collection as it is now (fonts -/// loaded since the last call included). None before Windows 10, or when DirectWrite fails: the -/// font folders and the registry still list the installed fonts. +/// loaded since the last call included), with those of the fonts GDI lists that the collection +/// leaves out. None before Windows 10, or when DirectWrite fails: the font folders and the +/// registry still list the installed fonts. fn font_files() -> Vec { - match collection_files() { + match listed_files() { Ok(files) => files.into_iter().collect(), Err(e) => { log::debug!("DirectWrite's system fonts aren't listed: {e}"); @@ -42,30 +59,48 @@ fn font_files() -> Vec { } } -fn collection_files() -> windows::core::Result> { +fn listed_files() -> windows::core::Result> { // SAFETY: plain DirectWrite calls on interfaces it returned, each checked for failure; the // collection is asked to check for fonts loaded since the factory last looked. - let set = unsafe { + let (factory, set) = unsafe { let factory: IDWriteFactory3 = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)?; let mut collection = None; factory.GetSystemFontCollection(false, &mut collection, true)?; - let Some(collection) = collection else { return Ok(BTreeSet::new()) }; - collection.GetFontSet()? + (factory, collection.map(|c| c.GetFontSet()).transpose()?) }; + let mut files = set.as_ref().map(collection_files).unwrap_or_default(); + // GDI's own names for the collection's families, the names GDI lists them by. + // SAFETY: a list DirectWrite returns, read by `strings`. + let named = set.as_ref().and_then(|s| unsafe { s.GetPropertyValues(DWRITE_FONT_PROPERTY_ID_WIN32_FAMILY_NAME) }.ok()); + let named: BTreeSet = named.as_ref().map(strings).unwrap_or_default().iter().map(|n| n.to_lowercase()).collect(); + match gdi_files(&factory, &named) { + Ok(more) => files.extend(more), + Err(e) => log::debug!("GDI's fonts aren't listed: {e}"), + } + Ok(files) +} + +fn collection_files(set: &IDWriteFontSet) -> BTreeSet { // SAFETY: a count of the set's fonts. let count = unsafe { set.GetFontCount() }.min(MAX_FONTS); // Faces of one file (a collection's, a variable font's instances) name it once. - Ok((0..count).filter_map(|i| local_path(&set, i)).collect()) + (0..count).filter_map(|i| local_path(set, i)).collect() } /// The path of the file holding font `i` of `set`, when it is a local file (not a downloadable /// font, nor one in memory). fn local_path(set: &IDWriteFontSet, i: u32) -> Option { - // SAFETY: `i` is below the set's count; the key DirectWrite hands back stays valid while - // `file` (which owns it) lives, and is passed back with its own size; the path buffer is - // sized from DirectWrite's answer plus the terminating NUL. + // SAFETY: `i` is below the set's count. + let file = unsafe { set.GetFontFaceReference(i).ok()?.GetFontFile().ok()? }; + file_path(&file) +} + +/// The path of `file`, when it is a local file. +fn file_path(file: &IDWriteFontFile) -> Option { + // SAFETY: the key DirectWrite hands back stays valid while `file` (which owns it) lives, and is + // passed back with its own size; the path buffer is sized from DirectWrite's answer plus the + // terminating NUL. unsafe { - let file = set.GetFontFaceReference(i).ok()?.GetFontFile().ok()?; let loader: IDWriteLocalFontFileLoader = file.GetLoader().ok()?.cast().ok()?; let (mut key, mut size) = (std::ptr::null_mut(), 0u32); file.GetReferenceKey(&mut key, &mut size).ok()?; @@ -79,6 +114,159 @@ fn local_path(set: &IDWriteFontSet, i: u32) -> Option { } } +/// The strings of `list`. +fn strings(list: &IDWriteStringList) -> Vec { + // SAFETY: indices below the list's count; each buffer is sized from DirectWrite's answer plus + // the terminating NUL. + let count = unsafe { list.GetCount() }.min(MAX_FONTS); + (0..count) + .filter_map(|i| unsafe { + let len = list.GetStringLength(i).ok()?; + if len > MAX_PATH { + return None; + } + let mut s = vec![0u16; len as usize + 1]; + list.GetString(i, &mut s).ok()?; + Some(String::from_utf16_lossy(s.get(..len as usize)?)) + }) + .collect() +} + +/// A memory device context, deleted when dropped. +struct MemoryDc(HDC); + +impl MemoryDc { + fn new() -> windows::core::Result { + // SAFETY: a device context compatible with the screen, owned by the guard. + let dc = unsafe { CreateCompatibleDC(None) }; + if dc.is_invalid() { Err(windows::core::Error::from_thread()) } else { Ok(Self(dc)) } + } +} + +impl Drop for MemoryDc { + fn drop(&mut self) { + // SAFETY: the guard's own device context, with its first font back in it. + // Failing to delete it leaks a handle, which there's nothing to do about. + let _ = unsafe { DeleteDC(self.0) }; + } +} + +/// The files of the faces of the font families GDI lists that DirectWrite's collection doesn't +/// name (`named`, lower case). +fn gdi_files(factory: &IDWriteFactory3, named: &BTreeSet) -> windows::core::Result> { + // SAFETY: a DirectWrite call checked for failure. + let interop = unsafe { factory.GetGdiInterop()? }; + let dc = MemoryDc::new()?; + let mut families = BTreeSet::new(); + enum_fonts(&dc, [0; 32], add_family, &mut families); + let mut faces = Vec::new(); + for family in families.iter().filter(|f| !named.contains(&face_name(f).to_lowercase())) { + enum_fonts(&dc, *family, add_face, &mut faces); + } + // A face listed once for each of its character sets is read once. + faces.sort_by_key(face_key); + faces.dedup_by_key(|f| face_key(f)); + // Asking for a face's files takes about a third of a millisecond, and the fonts are looked at + // every few seconds (#2576): the faces still listed keep the files they had while those exist. + // A face no longer listed is forgotten, so one activated again is asked for anew, and one + // whose files couldn't be read (GDI or DirectWrite failed, or it has no local file) is asked + // again at the next look. + let mut known = KNOWN_FACES.lock().unwrap_or_else(|e| e.into_inner()); + let mut now = BTreeMap::new(); + for f in &faces { + let key = face_key(f); + let files = match known.remove(&key) { + Some(files) if !files.is_empty() && files.iter().all(|p| std::path::Path::new(p).is_file()) => files, + _ => face_files(&interop, &dc, f), + }; + now.insert(key, files); + } + *known = now; + Ok(known.values().flatten().cloned().collect()) +} + +/// A GDI face: its name (NUL-padded), weight and whether it's italic. +type FaceKey = ([u16; 32], i32, u8); + +fn face_key(f: &LOGFONTW) -> FaceKey { + (f.lfFaceName, f.lfWeight, f.lfItalic) +} + +/// The files of the faces GDI listed at the last look ([`gdi_files`]). +static KNOWN_FACES: Mutex>> = Mutex::new(BTreeMap::new()); + +/// Have GDI call `add` with `out` for each font of the family named `family` (NUL-padded), or for +/// one font of each family when it's empty. +fn enum_fonts( + dc: &MemoryDc, + family: [u16; 32], + add: unsafe extern "system" fn(*const LOGFONTW, *const TEXTMETRICW, u32, LPARAM) -> i32, + out: &mut T, +) { + let query = LOGFONTW { lfCharSet: DEFAULT_CHARSET, lfFaceName: family, ..Default::default() }; + // SAFETY: `add` reads the fonts GDI passes and casts the LPARAM back to the `T` it is, which + // outlives the call (GDI calls back before returning). Its answer is the last callback's. + let _ = unsafe { EnumFontFamiliesExW(dc.0, &query, Some(add), LPARAM(std::ptr::from_mut(out) as isize), 0) }; +} + +/// A GDI font list callback adding the font's family name to the `BTreeSet<[u16; 32]>` in `out`. +unsafe extern "system" fn add_family(font: *const LOGFONTW, _: *const TEXTMETRICW, kind: u32, out: LPARAM) -> i32 { + // SAFETY: GDI passes a valid font, and `out` is the set `enum_fonts` was given. + let (Some(font), Some(out)) = (unsafe { font.as_ref() }, unsafe { (out.0 as *mut BTreeSet<[u16; 32]>).as_mut() }) else { + return 0; + }; + // Bitmap fonts have no file DirectWrite reads; `@` names are vertical writing's copies. + if kind & RASTER_FONTTYPE == 0 && font.lfFaceName.first().is_some_and(|&c| c != 0 && c != u16::from(b'@')) { + out.insert(font.lfFaceName); + } + i32::from(out.len() < MAX_GDI_FONTS) +} + +/// A GDI font list callback adding the font to the `Vec` in `out`. +unsafe extern "system" fn add_face(font: *const LOGFONTW, _: *const TEXTMETRICW, kind: u32, out: LPARAM) -> i32 { + // SAFETY: GDI passes a valid font, and `out` is the list `enum_fonts` was given. + let (Some(font), Some(out)) = (unsafe { font.as_ref() }, unsafe { (out.0 as *mut Vec).as_mut() }) else { + return 0; + }; + if kind & RASTER_FONTTYPE == 0 { + out.push(*font); + } + i32::from(out.len() < MAX_GDI_FONTS) +} + +/// `name`, NUL-padded, as a string. +fn face_name(name: &[u16; 32]) -> String { + String::from_utf16_lossy(name.split(|&c| c == 0).next().unwrap_or_default()) +} + +/// The local files of the DirectWrite font face GDI uses for `font`. +fn face_files(interop: &IDWriteGdiInterop, dc: &MemoryDc, font: &LOGFONTW) -> Vec { + // SAFETY: the font is selected into the guard's device context only while DirectWrite reads + // it, then the context's own font goes back and the font is deleted; the files array is + // sized from DirectWrite's count. + unsafe { + let gdi_font = CreateFontIndirectW(font); + if gdi_font.is_invalid() { + return Vec::new(); + } + let first = SelectObject(dc.0, HGDIOBJ::from(gdi_font)); + let face = interop.CreateFontFaceFromHdc(dc.0); + SelectObject(dc.0, first); + // Failing to delete it leaks a handle, which there's nothing to do about. + let _ = DeleteObject(HGDIOBJ::from(gdi_font)); + let Ok(face) = face else { return Vec::new() }; + let mut count = 0; + if face.GetFiles(&mut count, None).is_err() || count == 0 || count > MAX_FACE_FILES { + return Vec::new(); + } + let mut files = vec![None; count as usize]; + if face.GetFiles(&mut count, Some(files.as_mut_ptr())).is_err() { + return Vec::new(); + } + files.iter().flatten().filter_map(file_path).collect() + } +} + #[cfg(test)] mod tests { /// DirectWrite lists the installed fonts, by their files' full paths. @@ -89,4 +277,34 @@ mod tests { let fonts = std::path::Path::new(&std::env::var("WINDIR").unwrap()).join("Fonts").to_string_lossy().to_lowercase(); assert!(files.iter().any(|f| f.to_lowercase().starts_with(&fonts)), "{} files, {:?}", files.len(), files.first()); } + + /// A font loaded with GDI from a file without an extension outside the font folders, as + /// Creative Cloud loads Adobe Fonts, is listed by its file even though DirectWrite's collection + /// doesn't hold it (#1258): fonts loaded for one process never are. + #[test] + fn fonts_only_gdi_has_loaded_are_listed_by_their_files() { + use windows::Win32::Graphics::Gdi::{AddFontResourceExW, FR_PRIVATE, RemoveFontResourceExW}; + let dir = std::env::temp_dir().join(format!("vc-gdi-fonts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("29457"); + let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf"); + let mut data = std::fs::read(source).unwrap(); + // Renamed (to a name as long) so no installed Source Sans 3 stands in for it. + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16("Source Sans 3"), utf16("GdiOnly Sans3")), (b"Source Sans 3".to_vec(), b"GdiOnly Sans3".to_vec())] { + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + std::fs::write(&file, data).unwrap(); + let path = windows::core::HSTRING::from(file.as_os_str()); + assert_eq!(unsafe { AddFontResourceExW(&path, FR_PRIVATE, None) }, 1); + let files = super::font_files(); + let _ = unsafe { RemoveFontResourceExW(&path, FR_PRIVATE.0, None) }; + let _ = std::fs::remove_dir_all(&dir); + let wanted = file.to_string_lossy().to_lowercase(); + assert!(files.iter().any(|f| f.to_lowercase() == wanted), "{wanted} not in {} files", files.len()); + } } diff --git a/apps/vectorcraft/tests/it/help.rs b/apps/vectorcraft/tests/it/help.rs index 7c76240319..059b9c476c 100644 --- a/apps/vectorcraft/tests/it/help.rs +++ b/apps/vectorcraft/tests/it/help.rs @@ -1,19 +1,22 @@ //! `vectorcraft --help` prints the usage and exits before the app starts (#2110): run from a //! terminal, SSH or an agent's shell it used to open the window (or hang without a display). +use std::io::Read; use std::process::{Command, Output, Stdio}; +use std::sync::mpsc; use std::time::{Duration, Instant}; const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft"); -/// Runs the desktop binary without a display, so a regression fails instead of opening a window. +/// Runs the desktop binary without a display and with a control port it cannot parse, so a +/// regression exits with that error instead of opening a window. fn run(args: &[&str], env: &[(&str, &str)]) -> Output { let mut child = Command::new(BIN) .args(args) .env_remove("DISPLAY") .env_remove("WAYLAND_DISPLAY") .env_remove("WAYLAND_SOCKET") - .env_remove("VECTORCRAFT_CONTROL_PORT") + .env("VECTORCRAFT_CONTROL_PORT", "not-a-port") .env("VECTORCRAFT_NO_PREFS", "1") .envs(env.iter().copied()) .stdin(Stdio::null()) @@ -21,15 +24,28 @@ fn run(args: &[&str], env: &[(&str, &str)]) -> Output { .stderr(Stdio::piped()) .spawn() .unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while child.try_wait().unwrap().is_none() { - if Instant::now() >= deadline { - child.kill().unwrap(); - panic!("{args:?}: the app started instead of printing its usage"); - } - std::thread::sleep(Duration::from_millis(10)); - } - child.wait_with_output().unwrap() + // Both pipes end when the binary exits, however long a loaded machine takes to start it. The + // deadline only stops a binary that is still running. + let deadline = Instant::now() + Duration::from_secs(120); + let stdout = read_to_end(child.stdout.take().unwrap()); + let stderr = read_to_end(child.stderr.take().unwrap()); + let recv = |rx: &mpsc::Receiver>| rx.recv_timeout(deadline.saturating_duration_since(Instant::now())); + let (Ok(stdout), Ok(stderr)) = (recv(&stdout), recv(&stderr)) else { + child.kill().unwrap(); + panic!("{args:?}: the app started instead of printing its usage"); + }; + Output { status: child.wait().unwrap(), stdout, stderr } +} + +/// Reads `pipe` to its end on another thread. +fn read_to_end(mut pipe: impl Read + Send + 'static) -> mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = pipe.read_to_end(&mut buf); + let _ = tx.send(buf); + }); + rx } fn prints_usage(args: &[&str], env: &[(&str, &str)]) { diff --git a/contributors/people.toml b/contributors/people.toml index f8c6512390..d43e6fa1b7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -159,3 +159,13 @@ display_name = "Aditya Sisodiya" real_name = "Angelo Bernardini" display_name = "Angelo Bernardini" sync_github_name = false + +[people.DCaioP] +real_name = "Caio Pontes Magalhães" +display_name = "Caio Pontes" +sync_github_name = false + +[people.xbirks] +real_name = "Andrés Ortega Montoya" +display_name = "Andrés Ortega Montoya" +sync_github_name = false diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 8265d9a664..422f09d9c2 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -476,6 +476,18 @@ impl Brush { } } + /// [`Brush::art`], to change (relink the resources it uses). + pub fn art_mut(&mut self) -> Vec<&mut Node> { + match &mut self.kind { + BrushKind::Scatter(s) => vec![&mut s.art], + BrushKind::Art(a) => vec![&mut a.art], + BrushKind::Pattern(p) => std::iter::once(&mut p.side) + .chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten()) + .collect(), + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![], + } + } + /// A brush definition from JSON (a stored library entry or a command's params), its values /// kept within their ranges ([`Brush::sanitize`]). A Hue Shift colorization without a `key` /// takes the color the art's fills and strokes use most (the first of [`art_colors`]), or diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6fa9a2304b..6349f9f4cd 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -621,6 +621,29 @@ pub struct SavedView { pub rotation: f64, } +impl SavedView { + /// Most views a document keeps (the View menu lists every one). + pub const MAX: usize = 25; + /// Longest name, in characters. + pub const MAX_NAME: usize = 255; + + /// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank). + pub fn clean_name(name: &str) -> Option { + let name: String = name.trim().chars().take(Self::MAX_NAME).collect(); + (!name.is_empty()).then_some(name) + } + + /// The first "View N" none of `saved` is named. + pub fn default_name(saved: &[SavedView]) -> String { + (1..=saved.len() + 1).map(|i| format!("View {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default() + } + + /// Can the canvas show this view: a finite centre and a finite, positive zoom. + pub fn is_usable(&self) -> bool { + self.center.x.is_finite() && self.center.y.is_finite() && self.zoom.is_finite() && self.zoom > 0.0 + } +} + /// A saved selection (Select → Save Selection…): the objects that were selected, listed by name at /// the bottom of the Select menu. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -851,7 +874,7 @@ pub struct Document { pub graph_designs: Vec, #[serde(default)] pub guides: Vec, - /// View → New View… (up to 25, like Illustrator). + /// View → New View… (at most [`SavedView::MAX`]); saved with the document. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub views: Vec, /// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document. @@ -1843,6 +1866,40 @@ impl Document { } art } + /// Does any artwork lie on `rect` (an artboard)? Artwork is any object under the layers, + /// hidden and locked ones and those on template layers included, whose visual bounds (symbol + /// instances measured by their art) meet `rect`; touching its edge counts. A group (not a clip + /// group) counts by its members, so one whose members sit on two other artboards leaves the one + /// between them empty. A clip group or clipping layer counts by its members inside its clip (and + /// by its clipping path where that paints). Guides (paths made into guides too) aren't artwork + /// (Delete Empty Artboards). + pub fn artboard_has_art(&self, rect: Rect) -> bool { + // As in `art_on_artboard`: a millionth of a point covers rounding at the edges. + let rect = rect.inflate(1e-6, 1e-6); + fn meets(b: Option, r: Rect) -> bool { + b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1) + } + fn has(c: &Node, s: &SymbolBoxes, rect: Rect) -> bool { + match &c.kind { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + let Some((clip, rest)) = children.split_first() else { return false }; + let paint = clip.clip_paint(); + if (paint.fill.is_some() || paint.stroke.is_some()) && meets(clip.visual_bounds_with(s), rect) { + return true; + } + // The members show only inside the clip's box. + let Some(b) = c.visual_bounds_with(s).filter(|b| meets(Some(*b), rect)) else { return false }; + let inside = Rect::new(b.x0.max(rect.x0), b.y0.max(rect.y0), b.x1.min(rect.x1), b.y1.min(rect.y1)); + rest.iter().any(|m| has(m, s, inside)) + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect)), + NodeKind::Path { guide: true, .. } => false, + _ => meets(c.visual_bounds_with(s), rect), + } + } + let symbols = self.symbol_boxes(); + self.layers.iter().any(|l| has(l, &symbols, rect)) + } /// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`] /// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as /// locked art does. @@ -2100,6 +2157,28 @@ impl Document { self.saved_selections = kept.into(); } + /// Saved views as a file gives them, made safe to list and go to: at most [`SavedView::MAX`], + /// each with a clean, unique name, a finite centre and a finite, positive zoom (a rotation + /// that isn't finite becomes 0°). + pub fn tidy_views(&mut self) { + if self.views.is_empty() { + return; + } + let mut kept: Vec = vec![]; + for v in std::mem::take(&mut self.views) { + if kept.len() >= SavedView::MAX { + break; + } + let Some(name) = SavedView::clean_name(&v.name) else { continue }; + if !v.is_usable() || kept.iter().any(|k| k.name == name) { + continue; + } + let rotation = if v.rotation.is_finite() { v.rotation } else { 0.0 }; + kept.push(SavedView { name, rotation, ..v }); + } + self.views = kept; + } + /// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the /// mask already holds (the engine syncs it after every edit). pub fn drop_edit_modes(&mut self) { diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs new file mode 100644 index 0000000000..1c7de99787 --- /dev/null +++ b/crates/engine/src/cmd/brushlib.rs @@ -0,0 +1,502 @@ +//! Brush libraries (Window → Brush Libraries, the Brushes panel's library button): read-only sets +//! of brushes the library panel shows. A library is a document holding the brushes with the +//! resources their art uses (patterns, symbols, swatches, graphic styles, images); saved, it is a +//! native `.vectorcraft` file, which File › Open also opens. The built-in libraries are generated +//! here ([`BUILTIN`]); User Defined ones are the `.vectorcraft` files in the user library folder +//! ([`Libraries`]), and Other Library… loads the brushes of any document VectorCraft opens. +//! `brush.addFromLibrary` copies brushes into the document; `brush.saveLibrary` writes the +//! document's. + +use std::collections::HashSet; +use std::sync::{Arc, LazyLock}; + +use serde_json::{Value, json}; +use vectorcraft_brush::{self as brush, ArtBrush, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, PatternDef, Symbol}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, SubPath, shapes}; + +use super::brushsym::{apply_brush, brush_targets, roots, stroke_paint}; +use super::clipboard::Clipboard; +use super::clipboard::resources::SwatchChoices; +use super::patterncmds::add_pattern; +use super::swatch::str_list; +use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem}; +use super::*; + +/// The extension of saved brush libraries: native documents. +pub const BRUSHES_EXT: &str = vectorcraft_format::EXTENSION; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "brush.libraries", + "Brush Libraries", + [], + None, + "{} → {libraries: [{id, name, category: \"builtIn\"|\"user\" (User Defined: the .vectorcraft files in the user library folder, rescanned now)|\"loaded\" (brush.loadLibrary), count, path?}], userFolder} every brush library the library panel opens, in menu order", + always, + list + ), + cmd!( + query "brush.library", + "Brush Library", + [], + None, + "{library: id or name} → {id, name, category, brushes: [{name, type}] (as brush.list)}", + always, + get + ), + cmd!( + "brush.addFromLibrary", + "Add to Brushes", + ["Window", "Brush Libraries"], + None, + "{library: id or name, name? | names?: [brush names in the library] (default: all of it), apply?: false (also apply the first one to paths, as brush.apply does), ids?|id? (the paths apply takes; default the selected ones), swatchConflict? (as edit.paste)} copy library brushes into the document's Brushes as one undo step (with the apply; named Apply Brush when no brush is added and it only applies one), with the resources their art uses (patterns, symbols, swatches, graphic styles, images). A brush the document already has (same name and definition) isn't added again; a name another brush has gets a number (\"Ribbon 2\"). The first one becomes the current brush → {library, names: [each brush's name in the document], added: [names added], existing: [names already there], applied: [ids of the paths it was applied to]}", + has_doc, + add + ), + cmd!( + "brush.saveLibrary", + "Save Brush Library…", + ["Window", "Brushes"], + None, + "{path?, names?: [brush names] (default: all), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's brushes, with the resources their art uses, as a brush library: a .vectorcraft document holding only them → {path, count, library?: id when saved to the user folder}; without path or user → {dataBase64: the file's bytes, count}", + has_doc, + save + ), + cmd!( + "brush.loadLibrary", + "Other Library…", + ["Window", "Brush Libraries"], + None, + "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load the brushes of any document VectorCraft opens (see document.formats; one without brushes of its own has the default ones), a saved brush library included, for the library panel (Window → Brush Libraries lists it until the app quits; a file of the user library folder, or a file with the same bytes as one there, opens as that User Defined library) → {library: id, name, count}", + always, + load + ), + ] +} + +/// A brush library: its name, its brushes (in panel order) and a document holding them with the +/// resources their art uses; the document has no art of its own. +#[derive(Clone, Debug)] +pub struct BrushLibrary { + pub name: String, + pub brushes: Vec, + pub doc: Document, +} + +impl Default for BrushLibrary { + fn default() -> Self { + Self { name: String::new(), brushes: vec![], doc: Document::new(100.0, 100.0) } + } +} + +impl BrushLibrary { + pub fn len(&self) -> usize { + self.brushes.len() + } + pub fn is_empty(&self) -> bool { + self.brushes.is_empty() + } + pub fn brush(&self, name: &str) -> Option<&Brush> { + self.brushes.iter().find(|b| b.name == name) + } + + /// Document `src`'s brushes `names` (empty: all of them, in panel order) as a library named + /// `name`: the brushes and every resource their art uses. + pub fn from_document(src: &Document, names: &[String], name: String) -> std::result::Result { + let all = brush::library(src); + if let Some(n) = names.iter().find(|n| !all.iter().any(|b| b.name == **n)) { + return Err(format!("no brush `{n}`")); + } + let picked = all.iter().filter(|b| names.is_empty() || names.contains(&b.name)); + let clip = Clipboard::gather(src, picked.map(|b| stroke(&b.name)).collect()); + let mut doc = clip.to_document(); + for l in &mut doc.layers { + if let Some(ch) = Arc::make_mut(l).children_mut() { + ch.clear(); + } + } + // The brushes picked and those their art paints with, in the panel order of the document + // they came from. + let brushes: Vec = all.into_iter().filter(|b| clip.brushes.iter().any(|c| c.name == b.name)).collect(); + brush::store(&mut doc, &brushes); + doc.title = name.clone(); + Ok(Self { name, brushes, doc }) + } +} + +impl LibraryFile for BrushLibrary { + const EXTS: &'static [&'static str] = &[BRUSHES_EXT]; + fn read(bytes: &[u8], stem: &str) -> std::result::Result { + let doc = vectorcraft_format::load(bytes).map_err(|e| e.to_string())?; + BrushLibrary::from_document(&doc, &[], stem.to_string()) + } + fn name(&self) -> &str { + &self.name + } +} + +/// User Defined and loaded brush libraries. +pub type Libraries = super::swatchlib::Libraries; + +/// A path stroked with brush `name` (what the resource gathering follows). +fn stroke(name: &str) -> Node { + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(st) = ap.stroke_mut() { + st.brush = Some(name.into()); + } + let line = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false); + Node::path(NodeId(0), PathData::single(line), ap) +} + +// ---------- built-in libraries ---------- + +/// A library generated in code. +pub struct BuiltinBrushLibrary { + pub id: &'static str, + pub name: &'static str, + make: fn() -> BrushLibrary, +} + +/// The built-in brush libraries, in menu order: original brushes generated in code, one library +/// per kind of brush. +pub const BUILTIN: &[BuiltinBrushLibrary] = &[ + BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, + BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, + BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, + BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, +]; + +/// Built-in library `id`, made once. +fn builtin_library(id: &str) -> Option> { + static MADE: LazyLock>> = LazyLock::new(|| BUILTIN.iter().map(|b| Arc::new((b.make)())).collect()); + BUILTIN.iter().position(|b| b.id == id).and_then(|i| MADE.get(i).cloned()) +} + +/// Library `name` of `brushes`, `doc` holding the resources their art uses. +fn built(name: &str, mut doc: Document, brushes: Vec) -> BrushLibrary { + brush::store(&mut doc, &brushes); + doc.title = name.into(); + BrushLibrary { name: name.into(), brushes, doc } +} + +fn filled(p: PathData, paint: Paint) -> Node { + Node::path(NodeId(0), p, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint))], ..Default::default() }) +} + +fn black(p: PathData) -> Node { + filled(p, Paint::solid(Color::BLACK)) +} + +fn group(children: Vec) -> Node { + Node::group(NodeId(0), children.into_iter().map(Arc::new).collect()) +} + +fn polygon(pts: &[(f64, f64)]) -> PathData { + let pts: Vec = pts.iter().map(|&(x, y)| Point::new(x, y)).collect(); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// A band along y = `f(x)` for x in 0..`len`, `thick` pt thick, in `n` steps. +fn band(len: f64, thick: f64, n: usize, f: impl Fn(f64) -> f64) -> PathData { + let xs: Vec = (0..=n).map(|i| len * i as f64 / n as f64).collect(); + let mut pts: Vec = xs.iter().map(|&x| Point::new(x, f(x) - thick / 2.0)).collect(); + pts.extend(xs.iter().rev().map(|&x| Point::new(x, f(x) + thick / 2.0))); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// An even-odd ring of radii `outer` and `inner` around `c`. +fn ring(c: Point, outer: f64, inner: f64) -> Node { + let disc = |r: f64| shapes::ellipse(Rect::new(c.x - r, c.y - r, c.x + r, c.y + r)); + let children = [disc(outer), disc(inner)].into_iter().map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect(); + let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd }); + n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)))], ..Default::default() }.into(); + n +} + +fn art(name: &str, art: Node, colorization: Colorization) -> Brush { + Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art, colorization, ..Default::default() }) } +} + +/// Art Ribbons: art brushes, 100 pt long, drawn here; Striped Ribbon is filled with the library's +/// own pattern, Pinstripes. +fn art_ribbons() -> BrushLibrary { + let mut doc = Document::new(100.0, 100.0); + let stripe = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 6.0, 1.5)), { + let mut a = Appearance::default(); + a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::from_hex("#2f80c9").unwrap_or(Color::BLACK))))); + a + }); + add_pattern(&mut doc, PatternDef { tile: Rect::new(0.0, 0.0, 6.0, 3.0), ..PatternDef::new("Pinstripes", vec![Arc::new(stripe)]) }); + let notched = polygon(&[(0.0, -3.0), (100.0, -3.0), (95.0, 0.0), (100.0, 3.0), (0.0, 3.0), (5.0, 0.0)]); + let double = group(vec![black(shapes::rectangle(Rect::new(0.0, -2.5, 100.0, -1.5))), black(shapes::rectangle(Rect::new(0.0, 1.5, 100.0, 2.5)))]); + let wave = band(100.0, 1.6, 64, |x| 2.5 * (x / 100.0 * 4.0 * std::f64::consts::TAU).sin()); + // Round and thick at the start, tapering to a point. + let teardrop = { + let n = 48; + let half = |x: f64| 3.5 * (1.0 - x / 100.0).powf(0.8) * (x / 4.0).min(1.0).sqrt(); + let xs = |i: usize| 100.0 * i as f64 / n as f64; + let mut pts: Vec = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect(); + pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x)))); + PathData::single(SubPath::polyline(&pts, true)) + }; + let striped = { + let mut n = + filled(shapes::rectangle(Rect::new(0.0, -4.0, 100.0, 4.0)), Paint::Pattern { pattern: "Pinstripes".into(), xf: Default::default() }); + n.appearance.set_stroke(Paint::solid(Color::from_hex("#1d4f7c").unwrap_or(Color::BLACK))); + n + }; + let brushes = vec![ + art("Notched Ribbon", black(notched), Colorization::Tints), + art("Double Line", double, Colorization::Tints), + art("Wave", black(wave), Colorization::Tints), + art("Teardrop", black(teardrop), Colorization::Tints), + art("Striped Ribbon", striped, Colorization::None), + ]; + built("Art Ribbons", doc, brushes) +} + +fn nib(name: &str, angle: f64, roundness: f64, size: f64) -> Brush { + Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) } +} + +/// Calligraphic Nibs: pen nibs of several shapes; Pressure Pen's size follows the pen pressure. +fn calligraphic_nibs() -> BrushLibrary { + let pressure = Brush { + name: "Pressure Pen".into(), + kind: BrushKind::Calligraphic(Calligraphic { + angle: 30.0, + roundness: 60.0, + size: 5.0, + variation: [0.0, 0.0, 4.0], + modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]), + }), + }; + let brushes = vec![ + nib("Fine Nib", 45.0, 25.0, 2.0), + nib("Broad Nib", 45.0, 15.0, 9.0), + nib("Chisel", 0.0, 8.0, 6.0), + nib("Round Pen", 0.0, 100.0, 4.0), + pressure, + ]; + built("Calligraphic Nibs", Document::new(100.0, 100.0), brushes) +} + +fn border(name: &str, side: Node, corner: Option, fit: PatternFit) -> Brush { + let kind = BrushKind::Pattern(PatternBrush { side, outer_corner: corner.clone(), inner_corner: corner, fit, ..Default::default() }); + Brush { name: name.into(), kind } +} + +/// An invisible rectangle that fixes a tile's width. +fn spacer(r: Rect) -> Node { + Node::path(NodeId(0), shapes::rectangle(r), Appearance::default()) +} + +/// Pattern Borders: pattern brushes of tiles drawn here; Beads has corner tiles. +fn pattern_borders() -> BrushLibrary { + let zigzag = black(polygon(&[(0.0, 1.4), (5.0, -2.6), (10.0, 1.4), (10.0, 2.6), (5.0, -1.4), (0.0, 2.6)])); + let beads = group(vec![black(shapes::rectangle(Rect::new(0.0, -0.3, 8.0, 0.3))), black(shapes::ellipse(Rect::new(1.5, -2.5, 6.5, 2.5)))]); + let dash_dot = group(vec![ + spacer(Rect::new(0.0, -1.0, 12.0, 1.0)), + black(shapes::rounded_rectangle(Rect::new(0.5, -0.75, 6.5, 0.75), 0.75)), + black(shapes::ellipse(Rect::new(8.5, -1.0, 10.5, 1.0))), + ]); + let scallops = black(band(10.0, 1.0, 20, |x| -3.0 * (x / 10.0 * std::f64::consts::PI).sin())); + let brushes = vec![ + border("Zigzag", zigzag, None, PatternFit::Stretch), + border("Beads", beads, Some(ring(Point::ZERO, 3.5, 1.5)), PatternFit::Stretch), + border("Dash Dot", dash_dot, None, PatternFit::AddSpace), + border("Scallops", scallops, None, PatternFit::Stretch), + ]; + built("Pattern Borders", Document::new(100.0, 100.0), brushes) +} + +fn scatter(name: &str, art: Node, s: Scatter) -> Brush { + Brush { name: name.into(), kind: BrushKind::Scatter(Scatter { art, ..s }) } +} + +/// Scatter Shapes: scatter brushes of shapes drawn here; Sparkles scatters the library's own +/// symbol, Sparkle. +fn scatter_shapes() -> BrushLibrary { + let mut doc = Document::new(100.0, 100.0); + // A four-pointed gold sparkle in the ±10 pt box symbol instances assume. + let points: Vec = (0..8) + .map(|i| { + let r = if i % 2 == 0 { 10.0 } else { 2.5 }; + let a = std::f64::consts::FRAC_PI_4 * i as f64; + Point::new(r * a.cos(), r * a.sin()) + }) + .collect(); + let gold = Color::from_hex("#e0a526").unwrap_or(Color::BLACK); + let sparkle = Node::path(doc.alloc_id(), PathData::single(SubPath::polyline(&points, true)), { + let mut a = Appearance::default(); + a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(gold)))); + a + }); + doc.symbols.push(Symbol { name: "Sparkle".into(), art: Arc::new(sparkle) }); + let instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Sparkle".into(), xf: Affine::scale(0.3) }); + let random = |a: f64, b: f64| (a, b); + let base = Scatter { colorization: Colorization::Tints, ..Default::default() }; + let brushes = vec![ + scatter( + "Stars", + black(shapes::star(Point::ZERO, 4.0, 1.7, 5, 0.0)), + Scatter { + size: random(70.0, 130.0), + spacing: random(150.0, 220.0), + scatter: random(-80.0, 80.0), + rotation: random(-180.0, 180.0), + ..base.clone() + }, + ), + scatter("Rings", ring(Point::ZERO, 2.5, 1.5), Scatter { spacing: (140.0, 140.0), ..base.clone() }), + scatter( + "Triangles", + black(shapes::polygon(Point::ZERO, 3.0, 3, 0.0)), + Scatter { spacing: (120.0, 120.0), rotation: (90.0, 90.0), rotation_relative_to_path: true, ..base.clone() }, + ), + scatter( + "Sparkles", + instance, + Scatter { + size: random(60.0, 140.0), + spacing: random(110.0, 180.0), + scatter: random(-60.0, 60.0), + rotation: random(-45.0, 45.0), + colorization: Colorization::None, + ..base + }, + ), + ]; + built("Scatter Shapes", doc, brushes) +} + +// ---------- commands ---------- + +fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo { + LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" } +} + +/// Every brush library, in menu order: built-in, User Defined, loaded. +pub fn libraries(s: &Session) -> Vec { + BUILTIN.iter().map(builtin_info).chain(s.brush_libraries.infos().cloned()).collect() +} + +/// Library `key` (an id, or a name in any case) with its info. +pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc)> { + if let Some(found) = s.brush_libraries.get(key) { + return Some(found); + } + if let Some(b) = BUILTIN.iter().find(|b| b.id == key) { + return Some((builtin_info(b), builtin_library(key)?)); + } + let id = libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key))?.id; + library(s, &id) +} + +fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc)> { + let key = str_param(p, "library").ok_or_else(|| bad(cmd, "missing `library` (see brush.libraries)"))?; + library(s, key).ok_or_else(|| bad(cmd, format!("no brush library `{key}` (see brush.libraries)"))) +} + +fn list(s: &mut Session, _: &Value) -> Result { + s.brush_libraries.rescan(); + let libs: Vec = libraries(s) + .into_iter() + .map(|l| { + let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); + json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.brush_libraries.path(&l.id)}) + }) + .collect(); + Ok(json!({ "libraries": libs, "userFolder": s.brush_libraries.user_dir() })) +} + +fn get(s: &mut Session, p: &Value) -> Result { + let (info, lib) = library_param(s, p, "brush.library")?; + let brushes: Vec = lib.brushes.iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect(); + Ok(json!({"id": info.id, "name": info.name, "category": info.category, "brushes": brushes})) +} + +fn add(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.addFromLibrary"; + let (info, lib) = library_param(s, p, C)?; + let mut names = str_list(p, "names"); + names.extend(str_param(p, "name").map(str::to_string)); + if let Some(n) = names.iter().find(|n| lib.brush(n).is_none()) { + return Err(bad(C, format!("no brush `{n}` in `{}`", lib.name))); + } + let mut picked: Vec = if names.is_empty() { lib.brushes.iter().map(|b| b.name.clone()).collect() } else { names }; + // Each once, in the order asked for. + let mut seen = HashSet::new(); + picked.retain(|n| seen.insert(n.clone())); + let apply = bool_or(p, "apply", false); + if apply && picked.is_empty() { + return Err(bad(C, format!("`{}` has no brushes to apply", lib.name))); + } + let choices = SwatchChoices::parse(C, p)?; + let clip = Clipboard::gather(&lib.doc, picked.iter().map(|n| stroke(n)).collect()); + // The typing `s.edit` would settle first is settled before the probe below, so the document + // the edit installs is the one it starts from. + if !s.previewing && super::typing_in_progress(s) { + super::settle_interaction(s)?; + } + let st = s.doc()?; + let before: HashSet = brush::library(&st.doc).into_iter().map(|b| b.name).collect(); + // A library brush's name in the document: its own, or the one the import gave it. + let target = |renamed: &[Value], n: &str| -> String { + let to = renamed.iter().find(|r| r["kind"] == "brush" && r["from"] == n).and_then(|r| r["to"].as_str()); + to.unwrap_or(n).to_string() + }; + // Where the brushes land, worked out on a copy that the edit then installs: nothing to add and + // nothing to apply is no edit, and a click that only applies brushes the document has is + // named for that. + let mut probe = (*st.doc).clone(); + let imported = clip.import_into(&mut probe, &choices, false); + let doc_names: Vec = picked.iter().map(|n| target(&imported.renamed, n)).collect(); + let paths = if apply { brush_targets(&st.doc, &roots(s, p)?) } else { vec![] }; + let paint = stroke_paint(s); + if imported.added > 0 || !paths.is_empty() { + let label = if imported.added > 0 { "Add to Brushes" } else { "Apply Brush" }; + s.edit(label, |d, _| { + *d = probe; + if let Some(first) = doc_names.first() { + d.unknown.insert(brush::CURRENT_KEY.into(), json!(first)); + apply_brush(d, &paths, first, &paint); + } + Ok(()) + })?; + } else if let Some(first) = doc_names.first() { + s.execute("brush.setCurrent", &json!({ "name": first }))?; + } + let (added, existing): (Vec<&String>, Vec<&String>) = doc_names.iter().partition(|n| !before.contains(*n)); + let applied: Vec = paths.iter().map(|id| id.0).collect(); + Ok(json!({"library": info.id, "names": doc_names, "added": added, "existing": existing, "applied": applied})) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.saveLibrary"; + let st = s.doc()?; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string); + let lib = BrushLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?; + if lib.is_empty() { + return Err(bad(C, "the document has no brushes to save")); + } + let mut out = json!({ "count": lib.len() }); + let bytes = vectorcraft_format::save_file(&lib.doc); + s.brush_libraries.write(p, &lib.name, BRUSHES_EXT, FileData::Binary(bytes), &mut out, C)?; + Ok(out) +} + +fn load(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.loadLibrary"; + let (info, lib) = s.brush_libraries.load(p, C, |bytes, file| { + let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?; + if loaded.preview_only { + return Err(bad(C, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning")); + } + BrushLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e)) + })?; + Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) +} diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index f363f93f47..d485366eac 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -249,7 +249,7 @@ fn ids_json(ids: &[NodeId]) -> Value { Value::Array(ids.iter().map(|i| Value::from(i.0)).collect()) } -fn roots(s: &Session, p: &Value) -> Result> { +pub(crate) fn roots(s: &Session, p: &Value) -> Result> { match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { Some(mut v) => { // Explicit ids: only existing, non-layer objects, none inside another of them. @@ -269,7 +269,7 @@ fn name_param<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> { // ---------- brushes ---------- /// Brushable nodes (paths and compound paths) under `ids`. -fn brush_targets(d: &Document, ids: &[NodeId]) -> Vec { +pub(crate) fn brush_targets(d: &Document, ids: &[NodeId]) -> Vec { let mut out = vec![]; for id in ids { let Some(n) = d.node(*id) else { continue }; @@ -302,6 +302,27 @@ fn brush_selection_art(s: &mut Session, p: &Value) -> Result { Ok(json!({ "art": art.map(|n| serde_json::to_value(&n).unwrap_or_default()) })) } +/// The stroke paint a brush gives paths without one: the current stroke colour, else black. +pub(crate) fn stroke_paint(s: &Session) -> Paint { + if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() } +} + +/// Paint paths `ids` of `d` with brush `name` (a path without a stroke takes `stroke`). +pub(crate) fn apply_brush(d: &mut Document, ids: &[NodeId], name: &str, stroke: &Paint) { + for id in ids { + let Some(n) = d.node_mut(*id) else { continue }; + if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) { + n.appearance.set_stroke(stroke.clone()); + } + if let Some(st) = n.appearance.stroke_mut() { + if st.width <= 0.0 { + st.width = 1.0; + } + st.brush = Some(name.to_string()); + } + } +} + fn brush_apply(s: &mut Session, p: &Value) -> Result { const C: &str = "brush.apply"; let name = name_param(p, C)?.to_string(); @@ -309,21 +330,10 @@ fn brush_apply(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("no brush named `{name}`"))); } let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); - let stroke = if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() }; + let stroke = stroke_paint(s); s.edit("Apply Brush", |d, _| { d.unknown.insert(brush::CURRENT_KEY.into(), json!(name)); - for id in &ids { - let Some(n) = d.node_mut(*id) else { continue }; - if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) { - n.appearance.set_stroke(stroke.clone()); - } - if let Some(st) = n.appearance.stroke_mut() { - if st.width <= 0.0 { - st.width = 1.0; - } - st.brush = Some(name.clone()); - } - } + apply_brush(d, &ids, &name, &stroke); Ok(()) })?; Ok(json!({ "ids": ids_json(&ids) })) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index d51ef69072..a01f095fcd 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -23,6 +23,7 @@ use vectorcraft_geom::Rect; pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture}; pub(crate) use resources::SwatchChoices; +pub use resources::{ArtResources, art_resources}; use super::*; use crate::DocState; diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index ac01a81298..e6bd96371b 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -2,7 +2,7 @@ //! //! Objects refer to resources by name (image blobs, symbols, patterns, swatches, character and //! paragraph styles, brushes) and to graphic styles by id. Copy follows those references, also -//! through the symbols, patterns and styles it finds, and keeps what they lead to. +//! through the symbols, patterns, styles and brush art it finds, and keeps what they lead to. //! //! Pasting matches each resource by name: an identical one in the document is used, a missing one //! is added, and a different one of the same name is added under a free name, which the pasted @@ -13,6 +13,7 @@ //! the document's Spot Colors option says. Pasting back into the document the objects came from //! uses its resources as they are now. +use std::cell::OnceCell; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -147,10 +148,28 @@ struct Defs<'a> { styles: &'a [GraphicStyle], char_styles: &'a [TextStyleDef], para_styles: &'a [TextStyleDef], + brushes: Brushes<'a>, +} + +/// The brushes references lead to: a document's library, read the first time a brush is used +/// (`vectorcraft_brush::library` parses it), or the clipboard's own. +enum Brushes<'a> { + Lazy(&'a Document, &'a OnceCell>), + Ready(&'a [Brush]), +} + +impl<'a> Brushes<'a> { + fn get(&self) -> &'a [Brush] { + match *self { + Brushes::Lazy(d, cell) => cell.get_or_init(|| vectorcraft_brush::library(d)), + Brushes::Ready(list) => list, + } + } } impl<'a> Defs<'a> { - fn of(d: &'a Document) -> Self { + /// `d`'s definitions; `brushes` holds its brush library once a brush is used. + fn of(d: &'a Document, brushes: &'a OnceCell>) -> Self { Self { swatches: d.swatches_iter().collect(), symbols: &d.symbols, @@ -158,6 +177,7 @@ impl<'a> Defs<'a> { styles: &d.graphic_styles, char_styles: &d.char_styles, para_styles: &d.para_styles, + brushes: Brushes::Lazy(d, brushes), } } } @@ -180,9 +200,9 @@ pub(super) struct Used { } impl Used { - fn of<'a>(defs: &Defs<'a>, nodes: &'a [Node]) -> Self { + fn of<'a>(defs: &Defs<'a>, nodes: impl IntoIterator) -> Self { let mut used = Used::default(); - let mut todo: Vec> = nodes.iter().map(Source::Node).collect(); + let mut todo: Vec> = nodes.into_iter().map(Source::Node).collect(); while let Some(src) = todo.pop() { let mut found = |r: Ref<'_>| used.note(defs, r, &mut todo); match src { @@ -224,6 +244,8 @@ impl Used { Res::Pattern => todo.extend(defs.patterns.iter().filter(|p| p.name == name).flat_map(|p| p.art.iter().map(|a| Source::Node(a)))), Res::CharStyle => todo.extend(defs.char_styles.iter().filter(|s| s.name == name).map(|s| Source::Attrs(&s.attrs))), Res::ParaStyle => todo.extend(defs.para_styles.iter().filter(|s| s.name == name).map(|s| Source::Attrs(&s.attrs))), + // A scatter or art brush's art, a pattern brush's tiles. + Res::Brush => todo.extend(defs.brushes.get().iter().filter(|b| b.name == name).flat_map(|b| b.art().into_iter().map(Source::Node))), _ => {} } } @@ -242,6 +264,80 @@ impl Used { } } +/// What a preview of some art shows besides the art itself ([`art_resources`]). +pub struct ArtResources { + /// Changes when any resource the art uses changes, and only then. + pub key: u64, + /// The art and image bytes counted in `key` by their address, held weakly: their allocations + /// stay theirs (no other art can take an address) for as long as the key is kept. + _kept: (Vec>, Vec>>), +} + +/// The resources `art` uses in `doc`, through the definitions they lead to as Copy follows them +/// ([`Used`]): images, symbols, patterns (tile type, overlap and spacing too), swatches (linked +/// colours, tints, gradient swatches; colour groups' included), character and paragraph styles, +/// brushes and graphic styles, and the document's Spot Colors option. Art and image bytes count +/// by identity, the other definitions by their saved form: working the key out walks `art` and +/// the definitions it leads to, and never reads an image's bytes. +pub fn art_resources<'a>(doc: &'a Document, art: impl IntoIterator) -> ArtResources { + use std::hash::{Hash, Hasher}; + let library = OnceCell::new(); + let defs = Defs::of(doc, &library); + // The art's references shortened to the brush library's life. + let mut nodes: Vec<&Node> = vec![]; + for n in art { + nodes.push(n); + } + let used = Used::of(&defs, nodes); + let mut h = std::collections::hash_map::DefaultHasher::new(); + let mut kept = (vec![], vec![]); + fn saved(h: &mut impl Hasher, v: &impl serde::Serialize) { + serde_json::to_vec(v).unwrap_or_default().hash(h); + } + fn node(h: &mut impl Hasher, kept: &mut Vec>, a: &Arc) { + (Arc::as_ptr(a) as usize).hash(h); + kept.push(Arc::downgrade(a)); + } + for (res, names) in &used.names { + for name in names { + (*res as u8, name).hash(&mut h); + match res { + Res::Image => { + if let Some(b) = doc.images.get(name) { + b.mime.hash(&mut h); + for bytes in std::iter::once(&b.bytes).chain(&b.proxy) { + (Arc::as_ptr(bytes) as usize).hash(&mut h); + kept.1.push(Arc::downgrade(bytes)); + } + } + } + Res::Symbol => doc.symbols.iter().filter(|s| s.name == *name).for_each(|s| node(&mut h, &mut kept.0, &s.art)), + Res::Pattern => { + for p in doc.patterns.iter().filter(|p| p.name == *name) { + saved(&mut h, &PatternDef { art: vec![], ..p.clone() }); + p.art.iter().for_each(|a| node(&mut h, &mut kept.0, a)); + } + } + Res::CharStyle => doc.char_styles.iter().filter(|s| s.name == *name).for_each(|s| saved(&mut h, s)), + Res::ParaStyle => doc.para_styles.iter().filter(|s| s.name == *name).for_each(|s| saved(&mut h, s)), + Res::Brush => defs.brushes.get().iter().filter(|b| b.name == *name).for_each(|b| saved(&mut h, b)), + // Swatches below (tints count too); graphic styles go by id. + Res::Swatch | Res::GraphicStyle => {} + } + } + } + for w in doc.swatches_iter().filter(|w| used.carries(w) || used.has(Res::Swatch, &w.name)) { + saved(&mut h, w); + } + for g in doc.graphic_styles.iter().filter(|g| g.id != 0 && used.styles.contains(&g.id)) { + saved(&mut h, g); + } + if used.names.contains_key(&Res::Swatch) { + doc.spot_use_lab.hash(&mut h); + } + ArtResources { key: h.finish(), _kept: kept } +} + /// A resource list entry known by its name. trait Named: Clone { fn name(&self) -> &str; @@ -469,6 +565,26 @@ fn same_art(r: &Relink, ours: &[Arc], theirs: &[Arc]) -> bool { ours.len() == theirs.len() && ours.iter().zip(theirs).all(|(a, b)| relinked(&unlinked, a) == relinked(r, b)) } +/// Is `ours` (a document brush) brush `theirs` (a pasted one) once the art of that is relinked by +/// `r`? Graphic style links don't count, as in [`same_art`]. +fn same_brush(r: &Relink, ours: &Brush, theirs: &Brush) -> bool { + let relinked = |r: &Relink, b: &Brush| { + let mut b = b.clone(); + b.art_mut().into_iter().for_each(|a| r.node(a)); + b + }; + relinked(&Relink::default(), ours) == relinked(r, theirs) +} + +/// Does brush `b`'s art show symbols or patterns? +fn shows_definitions(b: &Brush) -> bool { + let mut found = false; + for a in b.art() { + node_refs(a, &mut |r| found |= matches!(r, Ref::Name(Res::Symbol | Res::Pattern, _))); + } + found +} + /// What pasting did to the document's resources (part of the paste result). #[derive(Debug, Default)] pub(crate) struct Imported { @@ -501,11 +617,13 @@ impl Imported { impl Clipboard { /// `nodes` (from `src`) and the resources of `src` they use. pub(crate) fn gather(src: &Document, nodes: Vec) -> Self { - let used = Used::of(&Defs::of(src), &nodes); + let library = OnceCell::new(); + let used = Used::of(&Defs::of(src, &library), &nodes); let pick = |r: Res, list: &[TextStyleDef]| list.iter().filter(|s| used.has(r, &s.name)).cloned().collect::>(); - let brushes = match used.names.get(&Res::Brush) { - Some(names) => vectorcraft_brush::library(src).into_iter().filter(|b| names.contains(&b.name)).collect(), - None => vec![], + // Read when a brush was used. + let brushes = match (used.names.get(&Res::Brush), library.get()) { + (Some(names), Some(library)) => library.iter().filter(|b| names.contains(&b.name)).cloned().collect(), + _ => vec![], }; Self { images: src.images.iter().filter(|(k, _)| used.has(Res::Image, k)).map(|(k, b)| (k.clone(), b.clone())).collect(), @@ -530,6 +648,7 @@ impl Clipboard { styles: &self.graphic_styles, char_styles: &self.char_styles, para_styles: &self.para_styles, + brushes: Brushes::Ready(&self.brushes), }; Used::of(&defs, &self.nodes) } @@ -549,8 +668,10 @@ impl Clipboard { let used = self.used(); let mut out = Imported::default(); self.import_images(d, &used, same_doc, &mut out); - self.import_brushes(d, &used, same_doc, &mut out); self.import_swatches(d, &used, choices, same_doc, &mut out); + // Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art + // they may brush; the others once those are in. + let mut added_brushes = self.import_brushes(d, &used, same_doc, false, &mut out); // Character and paragraph styles (their colours relinked first). for (r, mine, theirs) in [(Res::CharStyle, &self.char_styles, &mut d.char_styles), (Res::ParaStyle, &self.para_styles, &mut d.para_styles)] { for def in mine.iter().filter(|s| used.has(r, &s.name)) { @@ -583,6 +704,7 @@ impl Clipboard { added_patterns.extend(p.new_name(&def.name)); out.placed(Res::Pattern, &def.name, &p); } + added_brushes.extend(self.import_brushes(d, &used, same_doc, true, &mut out)); self.import_graphic_styles(d, &used, same_doc, &mut out); for s in d.symbols.iter_mut().filter(|s| added_symbols.contains(&s.name)) { out.relink.node(Arc::make_mut(&mut s.art)); @@ -592,6 +714,13 @@ impl Clipboard { out.relink.node(Arc::make_mut(a)); } } + if !added_brushes.is_empty() { + let mut lib = vectorcraft_brush::library(d); + for b in lib.iter_mut().filter(|b| added_brushes.contains(&b.name)) { + b.art_mut().into_iter().for_each(|a| out.relink.node(a)); + } + vectorcraft_brush::store(d, &lib); + } out } @@ -607,20 +736,26 @@ impl Clipboard { } } - fn import_brushes(&self, d: &mut Document, used: &Used, same_doc: bool, out: &mut Imported) { - let brushes: Vec<&Brush> = self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name)).collect(); + /// The brushes whose art shows symbols or patterns (`late`), or the others: each one the + /// document has (its art compared once relinked) is used, the others are added. → the names of + /// those added, whose art is relinked at the end. + fn import_brushes(&self, d: &mut Document, used: &Used, same_doc: bool, late: bool, out: &mut Imported) -> Vec { + let brushes: Vec<&Brush> = self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name) && shows_definitions(b) == late).collect(); if brushes.is_empty() { - return; + return vec![]; } let mut lib = vectorcraft_brush::library(d); - let before = lib.len(); + let mut added = vec![]; for b in brushes { - let p = place(&mut lib, b, same_doc, PartialEq::eq); + let r = &out.relink; + let p = place(&mut lib, b, same_doc, |ours, theirs| same_brush(r, ours, theirs)); + added.extend(p.new_name(&b.name)); out.placed(Res::Brush, &b.name, &p); } - if lib.len() != before { + if !added.is_empty() { vectorcraft_brush::store(d, &lib); } + added } /// Global and spot colours (a name conflict merges or adds as `choices` say), then the tint diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index dd841c661b..8c4e71f1ff 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -82,7 +82,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style?, color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -794,6 +794,9 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { } if let Some(f) = str_param(p, "style") { style.font_style = f.to_string(); + } else if str_param(p, "font").is_some() { + // A family without a Regular (#4128): its closest style, as text.setRangeStyle takes. + super::textedit::fit_font_style(&mut style); } style.fill = match p.get("color").and_then(color_value) { Some(c) => vectorcraft_color::Paint::solid(super::paint::new_color(s, p, c)), diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index ee3e474cd0..0ef78a06a4 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -843,10 +843,13 @@ fn freehand(s: &mut Session, p: &Value) -> Result { if let Some((si, sp)) = redraw_section(&pd, &pts, fit_tol, within) { s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { *path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp; + // A member path of a compound path is redrawn in it: the compound path is what is + // selected. + let whole = d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id); if keep_selected { - sel.set([id]); + sel.set([whole]); } else { - sel.remove(id); + sel.remove(whole); } Ok(()) })?; @@ -1494,6 +1497,7 @@ fn join_scrub(s: &mut Session, p: &Value) -> Result { .min_by(|a, b| a.1.total_cmp(&b.1)) .map_or(0.0, |x| x.0) }; + let (first_pt, last_pt) = (pts.first().copied(), pts.last().copied()); let mut ends: Vec<(f64, NodeId, usize, bool, Point)> = vec![]; for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, tol)) { let (pd, _) = path_of(&st.doc, id)?; @@ -1502,9 +1506,20 @@ fn join_scrub(s: &mut Session, p: &Value) -> Result { continue; } for (start, a) in [(true, sp.anchors[0].p), (false, sp.anchors[sp.anchors.len() - 1].p)] { - if dist_to_polyline(&pts, a) <= tol { - ends.push((order(a), id, si, start, a)); + let on_scrub = dist_to_polyline(&pts, a) <= tol; + let near_start = first_pt.is_some_and(|p| a.distance(p) <= tol); + let near_end = last_pt.is_some_and(|p| a.distance(p) <= tol); + if !on_scrub && !near_start && !near_end { + continue; } + let ord = if on_scrub { + order(a) + } else if near_start && (!near_end || first_pt.is_some_and(|p| a.distance(p) <= a.distance(last_pt.unwrap_or(p)))) { + -1.0 + } else { + 1e30 + }; + ends.push((ord, id, si, start, a)); } } } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index c6e759988b..56f5aa0d73 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -335,7 +335,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() { doc.title = title; } - warnings.extend(doc.images.values().filter_map(|b| too_large_note(&b.bytes))); + warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes))); Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data }) } @@ -515,8 +515,8 @@ pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC; /// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode, /// counted as RGBA (4 bytes a pixel) or as the decoder gives them when that is more (its header -/// says so; the pixels aren't read): Open, Place and the Links panel say it (#1479). `None` for -/// images within the allowance, and for bytes that aren't an image. +/// says so; the pixels aren't read): Open, Place and the Links panel say it ([`size_note`], #1479). +/// `None` for images within the allowance, and for bytes that aren't an image. pub(crate) fn too_large_note(bytes: &[u8]) -> Option { use image::ImageDecoder as _; if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -530,6 +530,17 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option { (rgba_over || d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw")) } +/// What Open, Place and the Links panel say of encoded image `bytes` that [`too_large_note`] is +/// said of: that the image is shown at reduced size when the renderer decodes it smaller +/// ([`vectorcraft_render::reduced_size`]), nothing when the renderer reads it at its own size a +/// row at a time, otherwise that note. Returns `None` when [`too_large_note`] returns `None`. +pub(crate) fn size_note(bytes: &[u8]) -> Option { + let note = too_large_note(bytes)?; + let Some(drawn) = vectorcraft_render::reduced_size(bytes) else { return Some(note) }; + let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?; + (drawn != (w, h)).then(|| format!("an image of {w} × {h} pixels is shown at reduced size")) +} + /// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as /// they are, with their ink amounts ([`ImageBlob::cmyk`]), except that ones storing one plane per /// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]); diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 2fb4cec009..e5ab9b9d24 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -32,7 +32,7 @@ mod export; pub(crate) mod imagemap; mod legacy_ai; mod load; -pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note}; +pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; mod metafile; mod naming; mod native; @@ -74,7 +74,7 @@ pub use native::with_compression_pref; pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, reads_option as save_reads_option, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 3eb64f3eaf..486777a247 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -185,6 +185,14 @@ fn parent_folder(path: &str) -> Option { std::path::Path::new(path).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()) } +/// A file dialog's starting folder for a path on disk (#4122): the path's parent when it is a +/// folder, else the nearest ancestor directory that exists. +pub fn dialog_folder(path: &str) -> Option { + let folder = parent_folder(path)?; + let path = std::path::PathBuf::from(folder); + path.ancestors().find(|a| a.is_dir()).map(|a| a.to_string_lossy().into_owned()) +} + /// A save format by id or extension, or why it isn't one. fn save_format_of(f: &str) -> std::result::Result<&'static Format, String> { match format(f) { @@ -300,7 +308,7 @@ fn disagreeing_option(p: &Value, svg: bool) -> std::result::Result Vec { has_doc, artboard_delete ), + cmd!( + "artboard.deleteEmpty", + "Delete Empty Artboards", + ["Window", "Artboards"], + None, + "{} delete every artboard with no artwork on it (no object, hidden, locked and template art included, whose visual bounds meet or touch it, clip groups and clipping layers by their members inside the clip; guides don't count), with its guides, in one step; locked artboards stay; when every artboard is empty and none is locked, artboard 1 stays (with a locked one left, artboard 1 can go) → {deleted: [index…]} (the indices before deleting)", + has_empty_artboards, + artboard_delete_empty + ), cmd!( "artboard.setProps", "Artboard Options…", @@ -863,21 +872,49 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { fn artboard_delete(s: &mut Session, p: &Value) -> Result { let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?; super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?; - s.edit("Delete Artboard", |d, sel| { - 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)); - } - Ok(()) - })?; + s.edit("Delete Artboard", |d, sel| remove_artboards(d, sel, &indices))?; 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<()> { + 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)); + } + Ok(()) +} + +/// 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). +pub(crate) fn empty_artboards(d: &Document) -> Vec { + let mut empty: Vec = d.artboards.iter().enumerate().filter(|(_, a)| !a.locked && !d.artboard_has_art(a.rect)).map(|(i, _)| i).collect(); + if empty.len() == d.artboards.len() { + empty.retain(|&i| i != 0); + } + empty +} + +fn has_empty_artboards(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if empty_artboards(&st.doc).is_empty() { Err("no empty artboards to delete".into()) } else { Ok(()) } +} + +fn artboard_delete_empty(s: &mut Session, _: &Value) -> Result { + let indices = empty_artboards(&s.doc()?.doc); + if indices.is_empty() { + return Err(EngineError::Other("no empty artboards to delete".into())); + } + s.edit("Delete Empty Artboards", |d, sel| remove_artboards(d, sel, &indices))?; + Ok(json!({ "deleted": indices })) +} + /// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color, lock, /// display marks (Show Center Mark, Show Cross Hairs, Show Video Safe Areas) and Video Ruler Pixel /// Aspect Ratio, how far it moved, whether it was resized, the map its art and guides take when diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index ef5be1322e..aef05cc8ad 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image too large to draw, document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}", has_doc, list ), @@ -1128,7 +1128,7 @@ fn list(s: &mut Session, p: &Value) -> Result { // Only an image that could be past the decode allowance (at 16 bytes a pixel, the // most a decoder gives) has its header read for this. let large = im.width as u64 * im.height as u64 > fileio::MAX_RASTER_ALLOC / 16; - if let Some(note) = st.doc.images.get(&im.key).filter(|_| large).and_then(|b| fileio::too_large_note(&b.bytes)) { + if let Some(note) = st.doc.images.get(&im.key).filter(|_| large).and_then(|b| fileio::size_note(&b.bytes)) { row["note"] = json!(note); } (im.link.as_ref(), row) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9c5d38158a..411f2004af 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod appearance; mod assets; mod attributes; +pub mod brushlib; mod brushsym; mod buildcmds; pub mod clipboard; @@ -271,6 +272,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(flatten::specs()); v.extend(stylelib::specs()); v.extend(symbollib::specs()); + v.extend(brushlib::specs()); v.extend(library::specs()); v.extend(expand::specs()); v.extend(attributes::specs()); @@ -564,6 +566,9 @@ pub use opacitymask::TransparencyInfo; /// Eyedropper Options (`Prefs::eyedropper`). pub use xform::{AppearanceAttrs, EyedropperAttrs, EyedropperOptions, FillAttrs, StrokeAttrs}; +/// Pathfinder Options (`Prefs::pathfinder`). +pub use pathops::{PATHFINDER_PRECISION_RANGE, PathfinderOptions}; + /// The Attributes panel's state ([`Session::attributes_info`]). pub use overprint::AttributesInfo; diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index b2a2aaec99..5b814b10bd 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -2,7 +2,7 @@ //! Character/Paragraph attributes. use serde_json::{Value, json}; -use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify}; +use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun}; use vectorcraft_geom::{Rect, Vec2}; use super::*; @@ -47,6 +47,15 @@ pub fn specs() -> Vec { has_doc, set_format ), + cmd!( + "text.convertListToText", + "Convert to Text", + [], + None, + "{ids?|id?, start?: byte, end?: byte} (Type › Bullets and Numbering › Convert to Text: the paragraphs of the selected type, or of `ids`, or those a start/end range touches) the marker of each list paragraph becomes characters of its text: the bullet, or the number and its period as the paragraph showed it, then a tab, at the start of the paragraph in the marker's font, size and color; the list is turned off. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, which keeps the text after the tab where it started; when it went to a tab stop and a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker and that place, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in place of any stop there and of those right, center and decimal stops before it (past the left indent only in a paragraph with no tab of its own). In threaded type the numbers are those counted through the story's frames, and a paragraph that continues into a later frame is converted where its text starts. An empty list paragraph has no marker and only leaves the list. A paragraph with no direction set gets the direction it ran in when the characters would change it (a letter or roman numeral before right-to-left text). With the Every-line Composer, a paragraph with a tab is broken line by line, so a converted paragraph can break its lines at other places. While the Type tool edits, its typing ends first and its anchor and caret move past the characters inserted at or before them; in threaded type, when the caret's character moved to another frame, the tool edits that frame, which becomes the selection when it was not selected. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}", + has_doc, + convert_list_to_text + ), ] } @@ -642,3 +651,355 @@ fn set_format(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } + +/// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame, +/// the frame's paragraph, its bytes there, its list, the marker the frame's layout draws for it +/// and the numbered paragraphs the frame counts before it. +struct ParagraphPart { + frame: usize, + index: usize, + bytes: std::ops::Range, + list: Option, + marker: Option, + count: u32, +} + +/// A paragraph of a story: its parts in thread order (a later frame's first paragraph goes on +/// with the paragraph the frame before it ends in, which may be an empty part after a paragraph +/// break), and whether the call converts it (a part of it is in a paragraph the call names). +struct StoryParagraph { + parts: Vec, + converts: bool, +} + +impl StoryParagraph { + /// The part whose style a re-flowed thread gives the whole paragraph + /// (`vectorcraft_text::thread::story_paras`) and whose marker the layout draws: the first part + /// with text, else the last part. + fn main_part(&self) -> Option<&ParagraphPart> { + self.parts.iter().find(|p| !p.bytes.is_empty()).or(self.parts.last()) + } +} + +/// The style of the character at byte `b` of `t`, as the layout reads it: the run that holds `b`, +/// else the one before it, else the first. +fn char_style_at(t: &vectorcraft_doc::TextObject, b: usize) -> vectorcraft_doc::CharStyle { + let mut runs = vec![]; + let mut off = 0; + for r in &t.runs { + runs.push((off..off + r.text.len(), &r.style)); + off += r.text.len(); + } + let find = |b: usize| runs.iter().find(|(r, _)| r.contains(&b)).map(|(_, s)| (*s).clone()); + find(b).or_else(|| b.checked_sub(1).and_then(find)).unwrap_or_else(|| t.first_style()) +} + +/// The list marker that `lay` (the layout of `t`) draws before paragraph `index` (bytes `bytes`): +/// the width of its characters and the width of the tab after them. A paragraph the layout leaves +/// out (overset text) is laid out on its own, as a paragraph that starts there, numbered on from +/// `count`. +fn marker_extent( + t: &vectorcraft_doc::TextObject, + lay: &vectorcraft_text::TextLayout, + index: usize, + bytes: std::ops::Range, + count: u32, +) -> (f64, f64) { + let width = |lay: &vectorcraft_text::TextLayout, start: usize| { + lay.markers.iter().find(|m| lay.glyphs.get(m.glyphs.start).is_some_and(|g| g.byte == start)).map(|m| { + let advance = |g: usize| lay.glyphs.get(g).map_or(0.0, |g| g.advance); + (m.glyphs.clone().filter(|&g| g != m.tab).map(advance).sum::(), advance(m.tab)) + }) + }; + width(lay, bytes.start).unwrap_or_else(|| { + let mut probe = t.clone(); + probe.kind = vectorcraft_doc::TextKind::Point; + probe.runs = vectorcraft_text::edit::slice_runs(&t.runs, bytes.start, bytes.end); + probe.ruby.clear(); + probe.set_all_paras(t.para_at(index).clone()); + probe.list_count = count; + probe.first_para_continues = false; + width(&vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe), 0).unwrap_or((0.0, 0.0)) + }) +} + +/// Whether tab stop `tab` is at position `x`. +fn stop_at(tab: &vectorcraft_doc::TabStop, x: f64) -> bool { + (tab.position - x).abs() <= 1e-6 +} + +/// Whether `tabs` hold one stop at position `x`, left-aligned and without a leader. +fn plain_stop_at(tabs: &[vectorcraft_doc::TabStop], x: f64) -> bool { + let there: Vec<&vectorcraft_doc::TabStop> = tabs.iter().filter(|tab| stop_at(tab, x)).collect(); + matches!(there.as_slice(), [only] if only.align == vectorcraft_doc::TabAlign::Left && only.leader.is_empty()) +} + +/// Whether tab stop `tab` lies between positions `from` and `x` and is not left-aligned. +fn aligned_between(tab: &vectorcraft_doc::TabStop, from: f64, x: f64) -> bool { + tab.align != vectorcraft_doc::TabAlign::Left && tab.position > from + 1e-6 && tab.position < x - 1e-6 +} + +/// The frames of the story that type object `id` belongs to (its thread in thread order, else `id` +/// alone), each with the length of its text in bytes. +fn story_frames(s: &Session, id: NodeId) -> Vec<(NodeId, usize)> { + let Some(st) = s.active() else { return vec![] }; + let d = &st.doc; + let frames = d.text_threads.iter().find(|thread| thread.contains(&id)).cloned().unwrap_or_else(|| vec![id]); + frames + .into_iter() + .filter_map(|f| match d.node(f).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some((f, t.plain_text().len())), + _ => None, + }) + .collect() +} + +/// After Convert to Text inserted `inserted` (each a frame, the byte it went in at and its length), +/// the Type tool's anchor and caret in frame `id` (`editing`, read before the call, in a story +/// whose frames had the lengths `before`) move past the characters inserted at or before them in +/// the story. In threaded type the caret stays in the frame it was in while its place is still +/// there, and else goes to the frame its character moved to, with the anchor in that frame; that +/// frame becomes the selection when it was not selected. +fn move_editing_selection( + s: &mut Session, + (id, anchor, caret): (NodeId, usize, usize), + before: &[(NodeId, usize)], + inserted: &[(NodeId, usize, usize)], +) -> Result<()> { + // Story positions: the bytes of the frames before a frame, then the byte in it. + let start_of = |frames: &[(NodeId, usize)], f: NodeId| frames.iter().take_while(|(g, _)| *g != f).map(|(_, n)| n).sum::(); + let len_of = |frames: &[(NodeId, usize)], f: NodeId| frames.iter().find(|(g, _)| *g == f).map_or(0, |(_, n)| *n); + let inserted: Vec<(usize, usize)> = + inserted.iter().filter(|(f, _, _)| before.iter().any(|(g, _)| g == f)).map(|&(f, at, n)| (start_of(before, f) + at, n)).collect(); + if inserted.is_empty() { + return Ok(()); + } + let story = |at: usize| { + let at = start_of(before, id) + at.min(len_of(before, id)); + at + inserted.iter().filter(|(b, _)| *b <= at).map(|(_, n)| n).sum::() + }; + let (anchor, caret) = (story(anchor), story(caret)); + let after = story_frames(s, id); + let fits = |f: NodeId| (start_of(&after, f)..=start_of(&after, f) + len_of(&after, f)).contains(&caret); + let frame = if fits(id) { Some(id) } else { after.iter().map(|(f, _)| *f).find(|f| fits(*f)) }; + let Some(frame) = frame else { return Ok(()) }; + let (start, len) = (start_of(&after, frame), len_of(&after, frame)); + let local = |at: usize| at.saturating_sub(start).min(len); + if frame == id { + s.set_tool_option("select", &json!({"start": local(anchor), "end": local(caret)})); + return Ok(()); + } + s.set_tool_option("textSelection", &json!({"editing": frame.0, "anchor": local(anchor), "caret": local(caret)})); + // The Type tool ends editing after a command when the frame it edits is not selected, itself + // or through a selected group, so that frame becomes the selection, as a click in it does. + let selected = s.active().is_some_and(|st| { + let mut cur = Some(frame); + while let Some(c) = cur { + if st.selection.contains(c) { + return true; + } + cur = st.doc.parent_of(c); + } + false + }); + if !selected { + s.select(|_, sel| sel.set([frame]))?; + } + Ok(()) +} + +/// Type › Bullets and Numbering › Convert to Text (`text.convertListToText`): the markers of the +/// list paragraphs the call names become characters of the text, and the lists are turned off. +/// While the Type tool edits, its typing ends first and its anchor and caret move past the +/// characters inserted at or before them ([`move_editing_selection`]). +fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.convertListToText"; + use vectorcraft_doc::text::ParaList; + let ids = super::typecmd::text_targets(s, p, C)?; + let range = super::typecmd::TextRange::parse(p, C)?; + let editing = match super::textedit::editing_range(s)? { + Some((id, _, _)) => { + let options = s.tool_options(); + let at = |k: &str| options.get(k).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()); + at("anchor").zip(at("caret")).map(|(anchor, caret)| (id, anchor, caret)) + } + None => None, + }; + let story_before = editing.map(|(id, _, _)| story_frames(s, id)).unwrap_or_default(); + let d = &s.doc()?.doc; + let text = |id: NodeId| match d.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t.as_ref()), + _ => None, + }; + // Each story once: the frames of a thread in thread order, or one type object. + let mut stories: Vec> = vec![]; + for id in &ids { + if stories.iter().any(|story| story.contains(id)) { + continue; + } + let thread = d.text_threads.iter().find(|thread| thread.contains(id)); + stories.push(thread.map_or_else(|| vec![*id], |thread| thread.iter().copied().filter(|f| text(*f).is_some()).collect())); + } + // Per frame: the paragraphs whose list goes (with the style each gets) and the characters that + // go in at a byte. + type Stops = Vec<(usize, vectorcraft_doc::ParaStyle)>; + type Inserts = Vec<(usize, TextRun)>; + let mut edits: Vec<(NodeId, Stops, Inserts)> = vec![]; + let mut converted = 0; + for story in &stories { + let frames: Vec<&vectorcraft_doc::TextObject> = story.iter().filter_map(|id| text(*id)).collect(); + let mut paras: Vec = vec![]; + let mut named_parts = vec![]; + let plains: Vec = frames.iter().map(|t| t.plain_text()).collect(); + for (f, ((id, t), plain)) in story.iter().zip(&frames).zip(&plains).enumerate() { + let named = if ids.contains(id) { range.map_or(0..t.paragraph_count(), |r| r.paras(t)) } else { 0..0 }; + let mut count = t.list_count; + let mut start = 0; + for (k, piece) in plain.split('\n').enumerate() { + let bytes = start..start + piece.len(); + start = bytes.end + 1; + let before = count; + let list = t.para_at(k).list; + let marker = ParaList::next_marker(list, piece.is_empty(), &mut count); + // A later frame's first paragraph goes on with the one the frame before it ends in. + if !(k == 0 && f > 0) || paras.is_empty() { + paras.push(StoryParagraph { parts: vec![], converts: false }); + } + if let Some(para) = paras.last_mut() { + para.parts.push(ParagraphPart { frame: f, index: k, bytes, list, marker, count: before }); + named_parts.push((paras.len() - 1, named.contains(&k))); + } + } + } + for (i, named) in named_parts { + if let Some(para) = paras.get_mut(i) { + let listed = para.main_part().is_some_and(|p| p.list.is_some()); + para.converts |= named && listed; + } + } + // Each converted paragraph's main part, whether the paragraph holds a tab of its own, and + // the paragraph. + let main_parts: Vec<(&ParagraphPart, bool, &StoryParagraph)> = paras + .iter() + .filter(|p| p.converts) + .filter_map(|p| { + let tabs = p.parts.iter().any(|q| plains.get(q.frame).and_then(|s| s.get(q.bytes.clone())).is_some_and(|s| s.contains('\t'))); + p.main_part().map(|m| (m, tabs, p)) + }) + .collect(); + let layouts: Vec> = frames + .iter() + .enumerate() + .map(|(f, t)| { + main_parts + .iter() + .any(|(p, _, _)| p.marker.is_some() && p.frame == f) + .then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)) + }) + .collect(); + let mut frame_edits: Vec<(Stops, Inserts)> = vec![(vec![], vec![]); frames.len()]; + for (para, own_tabs, story_para) in main_parts { + converted += 1; + let mut stop = None; + let mut direction = None; + if let (Some(marker), Some(t), Some(Some(lay))) = (¶.marker, frames.get(para.frame), layouts.get(para.frame)) { + // The marker's tab went to the left indent when it was ahead of the marker and no + // explicit stop came first, as the layout sets it; otherwise to the next tab stop, + // without the stop's leader, and for a right, center or decimal stop measured with + // the whole paragraph after it, where a converted tab measures the rest of its line. + // A plain left stop where the marker's tab ended takes the converted tab there: one + // at the left indent in the first case, and in the second, where a stop there or + // past it has a leader or is not left-aligned, or a right, center or decimal stop + // lies between the marker and that place. Those right, center and decimal stops + // between the marker and that place are removed. No other tab reaches a stop before + // the left indent, nor one past it in a paragraph with no tab of its own. + let style = t.para_at(para.index); + let (width, gap) = marker_extent(t, lay, para.index, para.bytes.clone(), para.count); + let pen = style.left_indent + style.first_line_indent + width; + let indent = style.left_indent; + let before = style.tabs.iter().any(|tab| tab.position > pen + 1e-6 && tab.position < indent - 1e-6); + let end = pen + gap; + let needed = |x: f64| !plain_stop_at(&style.tabs, x) || style.tabs.iter().any(|tab| aligned_between(tab, pen, x)); + if indent - pen > 1e-6 && !before { + stop = Some((pen, indent)).filter(|&(_, x)| needed(x)); + } else if (end < indent - 1e-6 || !own_tabs) + && style.tabs.iter().any(|tab| { + let past = tab.position > end - 1e-6 && (!tab.leader.is_empty() || tab.align != vectorcraft_doc::TabAlign::Left); + past || aligned_between(tab, pen, end) + }) + { + stop = Some((pen, end)).filter(|&(_, x)| needed(x)); + } + // A paragraph with no direction set runs the way its first strong character does. + // When the marker's characters would change that (a letter or a roman numeral + // before right-to-left text), the paragraph gets the direction it ran in. + if style.direction.is_none() { + let body = plains.get(para.frame).and_then(|s| s.get(para.bytes.clone())).unwrap_or_default(); + let was = vectorcraft_text::paragraph_is_rtl(body, None); + if vectorcraft_text::paragraph_is_rtl(&format!("{marker}\t{body}"), None) != was { + direction = Some(if was { vectorcraft_doc::ParaDirection::RightToLeft } else { vectorcraft_doc::ParaDirection::LeftToRight }); + } + } + let run = TextRun { text: format!("{marker}\t"), style: ParaList::marker_style(&char_style_at(t, para.bytes.start)), inline: None }; + if let Some((_, inserts)) = frame_edits.get_mut(para.frame) { + inserts.push((para.bytes.start, run)); + } + } + // A paragraph in several frames is changed in its main part and in its empty parts: + // re-flowing the thread gives the other parts with text the main part's style. + let Some(mut style) = frames.get(para.frame).map(|t| t.para_at(para.index).clone()) else { continue }; + style.list = None; + if direction.is_some() { + style.direction = direction; + } + // A plain left stop at `x`, in place of any stop there and of the right, center and + // decimal stops between the marker's end `from` and `x`. + if let Some((from, x)) = stop { + style.tabs.retain(|tab| !stop_at(tab, x) && !aligned_between(tab, from, x)); + let i = style.tabs.iter().position(|tab| tab.position > x).unwrap_or(style.tabs.len()); + let tab = vectorcraft_doc::TabStop { position: x, align: vectorcraft_doc::TabAlign::Left, leader: String::new(), align_on: '.' }; + style.tabs.insert(i, tab); + } + for part in story_para.parts.iter().filter(|q| std::ptr::eq(*q, para) || q.bytes.is_empty()) { + if let Some((stops, _)) = frame_edits.get_mut(part.frame) { + stops.push((part.index, style.clone())); + } + } + } + for (id, (stops, inserts)) in story.iter().zip(frame_edits) { + if !stops.is_empty() { + edits.push((*id, stops, inserts)); + } + } + } + let changed: Vec = edits.iter().map(|(id, _, _)| id.0).collect(); + let inserted: Vec = edits + .iter() + .flat_map(|(id, _, inserts)| inserts.iter().map(move |(at, run)| json!({"id": id.0, "at": at, "length": run.text.len()}))) + .collect(); + let insertions: Vec<(NodeId, usize, usize)> = + edits.iter().flat_map(|(id, _, inserts)| inserts.iter().map(move |(at, run)| (*id, *at, run.text.len()))).collect(); + if !edits.is_empty() { + s.edit("Convert to Text", |d, _| { + for (id, stops, mut inserts) in edits { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + for (k, style) in stops { + t.edit_paras(Some(k..k + 1), |para| *para = style.clone()); + } + // From the last paragraph to the first, so that each byte still names its place. + inserts.sort_by_key(|(at, _)| std::cmp::Reverse(*at)); + for (at, run) in inserts { + let len = run.text.len(); + vectorcraft_text::edit::replace_range_styled(&mut t.runs, at, at, &[run]); + t.ruby_after_replace(at, at, len); + } + super::typecmd::refresh_bounds(t); + } + Ok(()) + })?; + if let Some(editing) = editing { + move_editing_selection(s, editing, &story_before, &insertions)?; + } + } + Ok(json!({ "ids": changed, "converted": converted, "inserted": inserted })) +} diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index dbe1c868e5..8f3e0f117d 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -23,90 +23,90 @@ pub fn specs() -> Vec { "Unite", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Unite) + |s, p| run_pf(s, p, PathfinderOp::Unite) ), cmd!( "object.pathfinder.minusFront", "Minus Front", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::MinusFront) + |s, p| run_pf(s, p, PathfinderOp::MinusFront) ), cmd!( "object.pathfinder.intersect", "Intersect", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Intersect) + |s, p| run_pf(s, p, PathfinderOp::Intersect) ), cmd!( "object.pathfinder.exclude", "Exclude", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Exclude) + |s, p| run_pf(s, p, PathfinderOp::Exclude) ), cmd!( "object.pathfinder.divide", "Divide", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Divide) + |s, p| run_pf(s, p, PathfinderOp::Divide) ), cmd!( "object.pathfinder.trim", "Trim", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Trim) + |s, p| run_pf(s, p, PathfinderOp::Trim) ), cmd!( "object.pathfinder.merge", "Merge", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Merge) + |s, p| run_pf(s, p, PathfinderOp::Merge) ), cmd!( "object.pathfinder.crop", "Crop", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_multi, - |s, _| run_pf(s, PathfinderOp::Crop) + |s, p| run_pf(s, p, PathfinderOp::Crop) ), cmd!( "object.pathfinder.outline", "Outline", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::Outline) + |s, p| run_pf(s, p, PathfinderOp::Outline) ), cmd!( "object.pathfinder.minusBack", "Minus Back", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, - |s, _| run_pf(s, PathfinderOp::MinusBack) + |s, p| run_pf(s, p, PathfinderOp::MinusBack) ), cmd!( "object.pathfinder.trap", @@ -117,6 +117,15 @@ pub fn specs() -> Vec { has_selection, trap ), + cmd!( + "object.pathfinder.options", + "Pathfinder Options…", + ["Window", "Pathfinder"], + None, + "{precision?: pt 0.001..10 (0.01; how closely the Pathfinder operations follow the paths: results are refit to within it and pieces thinner than it are dropped), removeRedundantPoints?: bool (false; the results lose the anchors that don't change their shape), removeUnpainted?: bool (false; Divide and Outline leave out the pieces of objects with no fill)} Pathfinder Options, kept in the preferences and used by every later Shape Mode and Pathfinder operation (not Trap); {} reads them → the options", + always, + pathfinder_options + ), cmd!( "object.path.outlineStroke", "Outline Stroke", @@ -379,6 +388,82 @@ pub(crate) fn remove_each(d: &mut Document, ids: &[NodeId]) -> Result<()> { // ---------- Pathfinder ---------- +/// Pathfinder panel › Pathfinder Options (`object.pathfinder.options`, the `pathfinder` preference +/// group), which every Shape Mode and Pathfinder operation of the panel uses. The defaults leave +/// the results as they were before the options existed. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PathfinderOptions { + /// How closely results follow the paths (pt): they are refit to within it and pieces thinner + /// than it are dropped ([`po::pathfinder_with`]). + pub precision: f64, + /// Results lose the anchors that don't change their shape (to within `precision`). + pub remove_redundant_points: bool, + /// Divide and Outline leave out the pieces of objects with no fill. + pub remove_unpainted: bool, +} + +impl Default for PathfinderOptions { + fn default() -> Self { + Self { precision: po::DEFAULT_PRECISION, remove_redundant_points: false, remove_unpainted: false } + } +} + +/// The range of Pathfinder Options › Precision (pt). +pub const PATHFINDER_PRECISION_RANGE: std::ops::RangeInclusive = 0.001..=10.0; + +impl PathfinderOptions { + /// These options with those `p` names (`precision` in pt or a string with a unit), or why `p` + /// can't set them. + pub(crate) fn merged(&self, p: &Value) -> std::result::Result { + let given = match p { + Value::Null => return Ok(*self), + Value::Object(given) => given, + _ => return Err("`pathfinder` must be an object".into()), + }; + let mut o = *self; + for (key, v) in given { + let flag = || v.as_bool().ok_or_else(|| format!("`{key}` must be true or false")); + match key.as_str() { + "precision" => { + o.precision = + len_param(p, key).filter(|v| PATHFINDER_PRECISION_RANGE.contains(v)).ok_or("precision must be from 0.001 to 10 pt")?; + } + "removeRedundantPoints" => o.remove_redundant_points = flag()?, + "removeUnpainted" => o.remove_unpainted = flag()?, + k => { + let path = "pathfinder"; + return Err(format!("unknown option `{path}.{k}`")); + } + } + } + Ok(o) + } +} + +fn pathfinder_options(s: &mut Session, p: &Value) -> Result { + s.prefs.pathfinder = s.prefs.pathfinder.merged(p).map_err(|e| bad("object.pathfinder.options", e))?; + Ok(json!(s.prefs.pathfinder)) +} + +/// The last part of the id of `op`'s command (`object.pathfinder.…`). +fn pf_id(op: PathfinderOp) -> &'static str { + match op { + PathfinderOp::Unite => "unite", + PathfinderOp::MinusFront => "minusFront", + PathfinderOp::Intersect => "intersect", + PathfinderOp::Exclude => "exclude", + PathfinderOp::Divide => "divide", + PathfinderOp::Trim => "trim", + PathfinderOp::Merge => "merge", + PathfinderOp::Crop => "crop", + PathfinderOp::Outline => "outline", + PathfinderOp::MinusBack => "minusBack", + PathfinderOp::HardMix => "hardMix", + PathfinderOp::SoftMix => "softMix", + } +} + fn pf_label(op: PathfinderOp) -> &'static str { match op { PathfinderOp::Unite => "Unite", @@ -396,7 +481,25 @@ fn pf_label(op: PathfinderOp) -> &'static str { } } -fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { +/// The Pathfinder Options an operation uses: those `p` gives, else the preferences'. They join the +/// command's journal entry, so replaying it gives the same result whatever the preferences are then. +fn pf_options(s: &mut Session, p: &Value, id: &str) -> Result { + let mut stored = s.prefs.pathfinder; + if !PATHFINDER_PRECISION_RANGE.contains(&stored.precision) { + // A preferences file edited by hand: the default, rather than refitting everything away. + stored.precision = po::DEFAULT_PRECISION; + } + let keys = ["precision", "removeRedundantPoints", "removeUnpainted"]; + let given: serde_json::Map = + p.as_object().into_iter().flatten().filter(|(k, _)| keys.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); + let options = stored.merged(&Value::Object(given)).map_err(|e| bad(id, e))?; + s.note_journal("precision", json!(options.precision)); + s.note_journal("removeRedundantPoints", json!(options.remove_redundant_points)); + s.note_journal("removeUnpainted", json!(options.remove_unpainted)); + Ok(options) +} + +fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result { let roots = selected_roots(s)?; if roots.is_empty() { return Err(EngineError::Other("nothing selected".into())); @@ -404,6 +507,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { let shape_mode = matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack); let label = pf_label(op); + let options = pf_options(s, p, &format!("object.pathfinder.{}", pf_id(op)))?; let ids = s.edit(label, |d, sel| { // Build the stack (back → front); names are separate from paint/style identity. let mut styles: Vec