diff --git a/apps/vectorcraft-web/src/locks.rs b/apps/vectorcraft-web/src/locks.rs index 7eb2e6cdb7..dffd87868a 100644 --- a/apps/vectorcraft-web/src/locks.rs +++ b/apps/vectorcraft-web/src/locks.rs @@ -8,23 +8,40 @@ //! Pages that aren't secure (plain `http://` other than localhost) and older browsers have none: //! heartbeats alone decide there. +#[cfg(target_arch = "wasm32")] use std::cell::RefCell; +#[cfg(target_arch = "wasm32")] use std::collections::{BTreeMap, BTreeSet}; +#[cfg(target_arch = "wasm32")] use std::sync::{Arc, Mutex}; +#[cfg(target_arch = "wasm32")] use js_sys::{Function, Promise, Reflect}; +#[cfg(target_arch = "wasm32")] use vectorcraft_engine::cmd::recovery::Hold; +#[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast as _; +#[cfg(target_arch = "wasm32")] use wasm_bindgen::JsValue; +#[cfg(target_arch = "wasm32")] use wasm_bindgen::closure::Closure; /// How often the snapshot of the locks held is refreshed (ms). +#[cfg(target_arch = "wasm32")] const REFRESH_MS: i32 = 10_000; /// How old a snapshot may be and still be trusted (ms). A lock asked for after the snapshot was /// taken comes with a heartbeat, which stays fresh for at least three minutes, so anything under /// that is safe; an older snapshot counts every area as held. const FRESH_MS: f64 = 60_000.0; +/// Whether a snapshot asked for at `asked` can still be trusted at `now`, both in ms on +/// [`vectorcraft_ui_egui::now_ms`]'s clock: it is at most [`FRESH_MS`] old, and not stamped later +/// than `now`. +fn trusted(asked: f64, now: f64) -> bool { + (0.0..=FRESH_MS).contains(&(now - asked)) +} + +#[cfg(target_arch = "wasm32")] thread_local! { /// What releases each lock this tab holds, by name: it resolves the promise the lock is held /// by (wasm runs on one thread). @@ -32,26 +49,31 @@ thread_local! { } /// The locks of the page, named ``. +#[cfg(target_arch = "wasm32")] #[derive(Clone)] pub struct WebLocks { prefix: &'static str, - /// The areas whose locks were held at the last snapshot, and when it was asked for (ms since - /// the epoch). `navigator.locks.query()` is asynchronous, so the snapshot is refreshed every + /// The areas whose locks were held at the last snapshot, and when it was asked for (ms on + /// [`vectorcraft_ui_egui::now_ms`]'s monotonic clock, which a change to the system clock does + /// not move). `navigator.locks.query()` is asynchronous, so the snapshot is refreshed every /// [`REFRESH_MS`] and read from here. seen: Arc)>>, } /// `navigator.locks`, if the browser has it here. +#[cfg(target_arch = "wasm32")] fn manager() -> Option { let locks = Reflect::get(&web_sys::window()?.navigator(), &"locks".into()).ok()?; locks.is_object().then_some(locks) } /// Method `name` of `obj`. +#[cfg(target_arch = "wasm32")] fn method(obj: &JsValue, name: &str) -> Option { Reflect::get(obj, &name.into()).ok()?.dyn_into().ok() } +#[cfg(target_arch = "wasm32")] impl WebLocks { /// The page's Web Locks with a first snapshot of them, kept fresh from then on; `None` when /// the browser has none or won't say which are held (heartbeats alone decide then). @@ -75,7 +97,7 @@ impl WebLocks { /// Take a new snapshot of the locks held. async fn refresh(&self) -> Option<()> { let m = manager()?; - let asked = js_sys::Date::now(); + let asked = vectorcraft_ui_egui::now_ms(); let query: Promise = method(&m, "query")?.call0(&m).ok()?.dyn_into().ok()?; let snapshot = wasm_bindgen_futures::JsFuture::from(query).await.ok()?; let held = js_sys::Array::from(&Reflect::get(&snapshot, &"held".into()).ok()?); @@ -91,7 +113,7 @@ impl WebLocks { /// Does a tab hold `area`'s lock? Yes when the last snapshot is too old to tell. pub fn held(&self, area: &str) -> bool { let seen = self.seen.lock().unwrap_or_else(|e| e.into_inner()); - js_sys::Date::now() - seen.0 > FRESH_MS || seen.1.contains(area) + !trusted(seen.0, vectorcraft_ui_egui::now_ms()) || seen.1.contains(area) } /// Hold `area`'s lock until the returned hold is dropped (or the page goes). @@ -110,8 +132,10 @@ impl WebLocks { } /// A lock this tab holds (by name), released when dropped. +#[cfg(target_arch = "wasm32")] struct LockHold(String); +#[cfg(target_arch = "wasm32")] impl Drop for LockHold { fn drop(&mut self) { if let Some(resolve) = RELEASE.with(|r| r.borrow_mut().remove(&self.0)) { @@ -120,3 +144,24 @@ impl Drop for LockHold { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_snapshot_is_trusted_for_a_minute() { + assert!(trusted(1_000.0, 1_000.0)); + assert!(trusted(1_000.0, 1_000.0 + FRESH_MS)); + assert!(!trusted(1_000.0, 1_001.0 + FRESH_MS)); + // Before the first snapshot every area counts as held. + assert!(!trusted(f64::NEG_INFINITY, 0.0)); + } + + #[test] + fn a_snapshot_stamped_later_than_now_is_not_trusted() { + // A clock that went back reads an old snapshot as taken in the future. + assert!(!trusted(5_000_000.0, 1_000.0)); + assert!(!trusted(1_001.0, 1_000.0)); + } +} diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs index 3808c281f9..457d8d4640 100644 --- a/apps/vectorcraft-web/src/main.rs +++ b/apps/vectorcraft-web/src/main.rs @@ -25,7 +25,7 @@ mod api; mod fonts; #[cfg(any(target_arch = "wasm32", test))] mod keys; -#[cfg(target_arch = "wasm32")] +#[cfg(any(target_arch = "wasm32", test))] mod locks; #[cfg(any(target_arch = "wasm32", test))] mod pencil; diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 17b9327956..bcd37ecce3 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -11,9 +11,9 @@ //! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps //! copied), falling back to X11 where the compositor lacks the protocol, and where there is no //! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection: -//! Copy offers SVG (`image/svg+xml`), PNG (`image/png`) and text together, and Paste reads SVG, -//! text and PNG. A clipboard that can't be opened is tried again after a wait that grows to a -//! minute, not each time Paste asks. On Linux and the BSDs a read waits 2 s at most for the app +//! Copy offers SVG (`image/svg+xml`), PNG (`image/png`) and text together, and Paste reads copied +//! files (`text/uri-list`), SVG, text and PNG. A clipboard that can't be opened is tried again +//! after a wait that grows to a minute, not each time Paste asks. On Linux and the BSDs a read waits 2 s at most for the app //! that copied to send its data (a bitmap 10 s), and until that app answers, later reads fail at //! once; a paste then waits for that read's answer off the UI thread. //! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS, @@ -172,6 +172,85 @@ fn copied_file(paths: Vec, mimes: &[&'static str]) -> Option { }) } +/// The most URIs a copied file list may hold, VectorCraft's own limit: a longer list (untrusted +/// data) names no files. +const MAX_URIS: usize = 1024; + +/// The local files a `text/uri-list` names, in order ([`file_uri_path`]). Each line holds one URI +/// and a line that starts with `#` is a comment (RFC 2483, section 5); lines end in CRLF or LF. +/// Lines that are not UTF-8 and URIs that don't name a local file are skipped. A list of more than +/// [`MAX_URIS`] URIs names none. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn uri_list_paths(list: &[u8]) -> Vec { + let uris: Vec<&[u8]> = list + .split(|&b| b == b'\n') + .map(|line| line.strip_suffix(b"\r").unwrap_or(line)) + .filter(|line| !line.is_empty() && !line.starts_with(b"#")) + .take(MAX_URIS + 1) + .collect(); + if uris.len() > MAX_URIS { + return vec![]; + } + uris.into_iter().filter_map(|uri| file_uri_path(std::str::from_utf8(uri).ok()?)).collect() +} + +/// The local files GNOME Files' `x-special/gnome-copied-files` names: a first line of `copy` or +/// `cut`, then one URI a line, read as [`uri_list_paths`] reads them. A paste places the files' +/// art either way and leaves the files where they are. No files when the first line is neither. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn gnome_copied_paths(list: &[u8]) -> Vec { + let (action, uris) = match list.iter().position(|&b| b == b'\n') { + Some(end) => (list.get(..end).unwrap_or_default(), list.get(end + 1..).unwrap_or_default()), + None => (list, &[][..]), + }; + match action.strip_suffix(b"\r").unwrap_or(action) { + b"copy" | b"cut" => uri_list_paths(uris), + _ => vec![], + } +} + +/// The absolute path a `file:` URI names on this machine: `file:/path`, `file:///path` or +/// `file://localhost/path` (RFC 8089, section 2; scheme and host in any letter case), its percent +/// escapes decoded once (RFC 3986, section 2.1) and a fragment (`#…`) left out. None for another +/// scheme, another host, a relative path, a query (`?…`), a bad escape, an escaped NUL or a path +/// that is not UTF-8 once decoded. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn file_uri_path(uri: &str) -> Option { + let uri = uri.trim_matches(|c: char| c.is_ascii_whitespace()); + let uri = uri.split_once('#').map_or(uri, |(before, _)| before); + if uri.contains('?') { + return None; + } + let scheme = uri.get(..5).filter(|s| s.eq_ignore_ascii_case("file:"))?; + let rest = uri.get(scheme.len()..)?; + let path = match rest.strip_prefix("//") { + Some(authority_and_path) => { + let slash = authority_and_path.find('/')?; + let host = authority_and_path.get(..slash)?; + if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") { + return None; + } + authority_and_path.get(slash..)? + } + None => rest.starts_with('/').then_some(rest)?, + }; + let mut bytes = Vec::with_capacity(path.len()); + let mut it = path.bytes(); + while let Some(b) = it.next() { + if b == b'%' { + let hex = |d: Option| char::from(d?).to_digit(16); + let (hi, lo) = (hex(it.next())?, hex(it.next())?); + bytes.push(u8::try_from(hi * 16 + lo).ok()?); + } else { + bytes.push(b); + } + } + if bytes.contains(&0) { + return None; + } + String::from_utf8(bytes).ok().map(PathBuf::from) +} + #[cfg(windows)] mod win { use std::num::NonZeroU32; @@ -1094,7 +1173,9 @@ mod portable { } } - /// Files copied in a file manager (none where the backend can't read a file list). + /// Files copied in a file manager, read through arboard. The Wayland core protocol reads + /// its file list with the rest of a paste + /// ([`DataClipboard::read`](super::wayland::DataClipboard::read)). fn file_list(&mut self) -> Vec { match self { Self::Arboard(cb) => cb.read(Request::Files).files(), @@ -1233,8 +1314,9 @@ mod portable { } /// Text and copied files are read here, and work that reads arboard's image comes back. On - /// the Wayland core protocol the first of `mimes` the clipboard offers is read: text here, - /// SVG and PNG by the work that comes back, which waits [`IMAGE_WAIT`] at most. + /// the Wayland core protocol the copied files, else the first of `mimes` the clipboard + /// offers, is read: text here, file lists, SVG and PNG by the work that comes back, which + /// waits [`IMAGE_WAIT`] at most. fn read_now_or_later(&mut self, mimes: &[&'static str]) -> Option { let cb = self.cb(Asker::User).ok()?; #[cfg(target_os = "linux")] @@ -1335,9 +1417,9 @@ mod portable { } /// Text, on macOS vector art or a bitmap (by the pasteboard's types), and on the Wayland - /// core protocol SVG, text or PNG (by the types offered, nothing read). arboard has no - /// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys - /// take it, the menu item waits for text. + /// core protocol copied files, SVG, text or PNG (by the types offered, nothing read). + /// arboard has no cheap way to ask for a bitmap (reading one costs as much as pasting it): + /// the paste keys take it, the menu item waits for text. fn has(&mut self, mimes: &[&'static str]) -> bool { #[cfg(target_os = "macos")] if super::mac::has(mimes) { @@ -1784,12 +1866,13 @@ mod portable { /// window's own Wayland connection: what Copy and Paste use where the compositor lacks the /// data-control protocol arboard needs (GNOME) and there is no X11 to fall back on (the Flatpak on /// a Wayland session). A Copy offers its SVG as `image/svg+xml`, its PNG as `image/png` and its -/// text under each of [`TEXT_TYPES`]; Paste reads the clipboard's SVG, text and PNG, and the paste -/// check looks at the types offered without reading any data. The protocol gives the clipboard to -/// the window with the keyboard focus only. A worker thread handles the protocol's events and -/// starts a thread for each paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads -/// run on another worker and take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, SVG and -/// PNG as long as a paste's bitmap from arboard. A read that gets no answer in its wait is kept for +/// text under each of [`TEXT_TYPES`]; Paste reads the files a file manager copied +/// ([`FILE_LISTS`]), then the clipboard's SVG, text and PNG, and the paste check looks at the types +/// offered without reading any data. The protocol gives the clipboard to the window with the +/// keyboard focus only. A worker thread handles the protocol's events and starts a thread for each +/// paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads run on another worker and +/// take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, file lists, SVG and PNG as long as +/// a paste's bitmap from arboard. A read that gets no answer in its wait is kept for /// a paste to wait for off the UI thread, as arboard's are. A read or send whose pipe moves no data /// for [`IDLE_WAIT`] fails and closes the pipe, which frees its worker or thread. #[cfg(target_os = "linux")] @@ -1817,11 +1900,17 @@ mod wayland { use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, WEnum}; use super::loads::{Asker, GONE, LOAD_WAIT, Loads, wait_for_answer}; - use super::{BITMAP, Flavour, PNG, SVG, TEXT}; + use super::{BITMAP, Flavour, PNG, PathBuf, SVG, TEXT, copied_file, gnome_copied_paths, uri_list_paths}; /// The types text is offered under and read from, best first: `text/plain;charset=utf-8` (GTK /// and Qt apps), `UTF8_STRING` (X11 apps through XWayland) and `text/plain`. const TEXT_TYPES: [&str; 3] = ["text/plain;charset=utf-8", "UTF8_STRING", "text/plain"]; + /// What reads the files a file list names. + type PathsOf = fn(&[u8]) -> Vec; + /// The types files copied in a file manager are offered under, each with what reads its files, + /// best first: `text/uri-list` (RFC 2483), and GNOME Files' own list, read only when the first + /// names no file. + const FILE_LISTS: [(&str, PathsOf); 2] = [("text/uri-list", uri_list_paths), ("x-special/gnome-copied-files", gnome_copied_paths)]; /// The most bytes a read takes from the app that copied (untrusted data), VectorCraft's own /// limit: a read of more fails. const MAX_BYTES: usize = 64 << 20; @@ -1905,6 +1994,35 @@ mod wayland { Some(first) } + /// Whether a paste of one of `mimes` can take a copied file: one of them is not text. + fn takes_files(mimes: &[&'static str]) -> bool { + mimes.iter().any(|&m| m != TEXT) + } + + /// The first type among `offered` that lists copied files ([`FILE_LISTS`]), in any letter case. + fn file_list_type(offered: &[String]) -> Option<&str> { + FILE_LISTS.iter().find_map(|(list, _)| offered.iter().find(|o| o.eq_ignore_ascii_case(list))).map(String::as_str) + } + + /// The first file a file manager copied that pastes as one of `mimes` ([`copied_file`]), from + /// the first of [`FILE_LISTS`] among `offered` that names a file, `read` reading each list. + fn copied(offered: &[String], mimes: &[&'static str], mut read: impl FnMut(&str) -> Option>) -> Option { + if !takes_files(mimes) { + return None; + } + let paths = FILE_LISTS.iter().find_map(|(list, paths_of)| { + let wire = offered.iter().find(|o| o.eq_ignore_ascii_case(list))?; + Some(paths_of(&read(wire)?)).filter(|paths| !paths.is_empty()) + })?; + copied_file(paths, mimes) + } + + /// What a paste of one of `mimes` takes: the first copied file that is art ([`copied`]), else + /// what [`pick`] takes. + fn pick_pasted(offered: &[String], mimes: &[&'static str], mut read: impl FnMut(&str) -> Option>) -> Option { + copied(offered, mimes, &mut read).or_else(|| pick(offered, mimes, read)) + } + /// The time `idle` from now; None when that is past the clock's range (no limit). fn idle_until(idle: Duration) -> Option { Instant::now().checked_add(idle) @@ -2089,9 +2207,9 @@ mod wayland { impl Unanswered { /// What a paste of one of `mimes` takes once this read's answer comes, waiting for it until - /// `limit` after the read was sent: the clipboard read again as [`pick`] reads it, each read - /// waiting as long, [`LOAD_WAIT`] at least, since the app that answers late may have lost the - /// clipboard meanwhile. None when the answer doesn't come or the clipboard holds this + /// `limit` after the read was sent: the clipboard read again as [`pick_pasted`] reads it, + /// each read waiting as long, [`LOAD_WAIT`] at least, since the app that answers late may + /// have lost the clipboard meanwhile. None when the answer doesn't come or the clipboard holds this /// connection's Copy. pub(super) fn paste(self, mimes: &[&'static str], limit: Duration) -> Option { let left = |now: Instant| (self.sent + limit).saturating_duration_since(now); @@ -2103,7 +2221,9 @@ mod wayland { } seen.offered.clone() }; - pick(&offered, mimes, |wire| wait_for_answer(&self.reads.ask(wire.to_string()).ok()?, LOAD_WAIT.max(left(Instant::now()))).ok()?.ok()) + pick_pasted(&offered, mimes, |wire| { + wait_for_answer(&self.reads.ask(wire.to_string()).ok()?, LOAD_WAIT.max(left(Instant::now()))).ok()?.ok() + }) } } @@ -2136,12 +2256,36 @@ mod wayland { } } - /// What a paste of one of `mimes` reads: the first the clipboard offers. Text is read here, - /// [`LOAD_WAIT`] at most, and pasted unless it is empty; SVG and PNG are asked for here and - /// waited for, `wait` at most, by the work that comes back. When that read fails or is - /// empty, the work reads the rest of `mimes` as [`pick`] does, each read waiting until - /// `wait` after the first was sent, [`LOAD_WAIT`] at least. + /// What a paste of one of `mimes` reads: the files a file manager copied, else the first of + /// `mimes` the clipboard offers. A file list is asked for here and read, with the files, + /// by the work that comes back, which pastes the first file that is art ([`copied`]) and + /// else reads `mimes` as [`pick`] does. Text is read here, [`LOAD_WAIT`] at most, and + /// pasted unless it is empty; SVG and PNG are asked for here and waited for, `wait` at most, + /// by the work that comes back. When that read fails or is empty, the work reads the rest + /// of `mimes` as [`pick`] does. Each read the work makes after the first waits until `wait` + /// after the first was sent, [`LOAD_WAIT`] at least. pub(super) fn read(&mut self, mimes: &[&'static str], wait: Duration) -> Option { + if takes_files(mimes) { + let offered = lock(&self.seen).offered.clone(); + if let Some(list) = file_list_type(&offered).map(str::to_string) { + let sent = Instant::now(); + let answer = self.loads.ask(list.clone()).ok()?; + self.unanswered = None; + let (reads, mimes) = (self.loads.asker(), mimes.to_vec()); + return Some(ClipboardRead::Later(Box::new(move || { + let left = || LOAD_WAIT.max((sent + wait).saturating_duration_since(Instant::now())); + // The list asked for here is read from its answer, every other type anew. + let mut first = Some((list, answer)); + pick_pasted(&offered, &mimes, |wire| { + let answer = match first.take_if(|(list, _)| list.as_str() == wire) { + Some((_, answer)) => answer, + None => reads.ask(wire.to_string()).ok()?, + }; + wait_for_answer(&answer, left()).ok()?.ok() + }) + }))); + } + } mimes.iter().enumerate().find_map(|(i, &mime)| { let wire = self.wire(mime)?; if mime == TEXT { @@ -2163,10 +2307,11 @@ mod wayland { }) } - /// Whether the clipboard offers one of `mimes`; nothing is read. + /// Whether the clipboard offers one of `mimes`, or files a file manager copied when a paste + /// of `mimes` can take a file; nothing is read. pub(super) fn has(&self, mimes: &[&'static str]) -> bool { let seen = lock(&self.seen); - mimes.iter().any(|m| wire_type(m, &seen.offered).is_some()) + (takes_files(mimes) && file_list_type(&seen.offered).is_some()) || mimes.iter().any(|m| wire_type(m, &seen.offered).is_some()) } /// Whether the clipboard holds this connection's last Copy. @@ -2835,6 +2980,85 @@ mod wayland { let Some(ClipboardRead::Later(work)) = reads.read(&[BITMAP], Duration::from_secs(600)) else { panic!("the PNG is asked for") }; assert!(work().is_none()); } + + /// Reads of a clipboard holding `data` by type, answered at once. + fn reads_holding(data: Vec<(String, Vec)>) -> Reads { + let types: Vec = data.iter().map(|(t, _)| t.clone()).collect(); + let loads = Loads::spawn("test", Duration::ZERO, move |wire: String| { + data.iter().find(|(t, _)| *t == wire).map(|(_, d)| d.clone()).ok_or_else(|| "not offered".to_string()) + }) + .unwrap(); + let types: Vec<&str> = types.iter().map(String::as_str).collect(); + Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&types)))) + } + + /// The `file:` URI of `path`, its spaces escaped. + fn file_uri(path: &std::path::Path) -> String { + format!("file://{}", path.display().to_string().replace(' ', "%20")) + } + + /// What a paste of `mimes` takes from `reads`. + fn paste(reads: &mut Reads, mimes: &[&'static str]) -> Option<(&'static str, Vec)> { + match reads.read(mimes, Duration::from_secs(600))? { + ClipboardRead::Now(f) => Some((f.mime, f.data)), + ClipboardRead::Later(work) => work().map(|f| (f.mime, f.data)), + } + } + + #[test] + fn files_copied_in_a_file_manager_paste_as_their_art() { + use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; + let d = tempfile::Builder::new().prefix("vectorcraft-wayland-files-").tempdir().unwrap(); + let (notes, red) = (d.path().join("notes.txt"), d.path().join("red image.png")); + let mut png = vec![]; + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + std::fs::write(¬es, "not art").unwrap(); + std::fs::write(&red, &png).unwrap(); + let list = |paths: &[&std::path::Path]| paths.iter().map(|p| file_uri(p) + "\r\n").collect::().into_bytes(); + let held = |types: &[(&str, Vec)]| reads_holding(types.iter().map(|(t, d)| (t.to_string(), d.clone())).collect()); + let text = ("text/plain;charset=utf-8", b"the files' paths".to_vec()); + // A file list alone enables Paste and pastes the first file that is art. + let mut reads = held(&[("text/uri-list", list(&[¬es, &red]))]); + assert!(reads.has(&PASTE_ORDER) && reads.has(&[BITMAP])); + assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((PNG, png.clone()))); + // A paste of text alone takes no file. + assert!(!reads.has(&[TEXT])); + assert_eq!(paste(&mut reads, &[TEXT]), None); + // The files come before the other types offered, as on the other platforms; the type in + // any letter case. + let mut reads = held(&[(SVG, b"".to_vec()), text.clone(), ("Text/URI-List", list(&[&red]))]); + assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((PNG, png.clone()))); + assert_eq!(paste(&mut reads, &[TEXT]), Some((TEXT, text.1.clone()))); + // With no file that is art, the paste takes the other types. + let mut reads = held(&[("text/uri-list", list(&[¬es, &d.path().join("gone.png")])), text.clone()]); + assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((TEXT, text.1.clone()))); + let mut reads = held(&[("text/uri-list", b"https://example.com/art.png\r\n".to_vec())]); + assert_eq!(paste(&mut reads, &PASTE_ORDER), None); + // GNOME Files' own list, alone or when the URI list names no local file. + let gnome = [b"copy\n".to_vec(), file_uri(&red).into_bytes()].concat(); + let mut reads = held(&[("x-special/gnome-copied-files", gnome.clone())]); + assert!(reads.has(&PASTE_ORDER)); + assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((PNG, png.clone()))); + let mut reads = held(&[("text/uri-list", b"file://server/art.png\r\n".to_vec()), ("x-special/gnome-copied-files", gnome)]); + assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((PNG, png.clone()))); + // KDE's cut marker is not a file list. + let mut reads = held(&[("application/x-kde-cutselection", b"1".to_vec())]); + assert!(!reads.has(&PASTE_ORDER)); + assert_eq!(paste(&mut reads, &PASTE_ORDER), None); + } + + #[test] + fn a_file_list_that_cant_be_read_gives_way_to_the_other_types() { + use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; + let offered = offered(&["text/uri-list", "text/plain"]); + let mut asked = vec![]; + let f = pick_pasted(&offered, &PASTE_ORDER, |wire| { + asked.push(wire.to_string()); + (wire == "text/plain").then(|| b"words".to_vec()) + }); + assert_eq!(f.map(|f| (f.mime, f.data)), Some((TEXT, b"words".to_vec()))); + assert_eq!(asked, ["text/uri-list", "text/plain"]); + } } } @@ -2952,6 +3176,70 @@ mod tests { assert_eq!(copied_file(vec![with_cr], &PASTE_ORDER).map(|f| f.mime), Some(PNG)); } + fn uri_paths(list: &[u8]) -> Vec { + uri_list_paths(list).into_iter().map(|p| p.to_string_lossy().into_owned()).collect() + } + + #[test] + fn a_uri_list_names_the_local_files_of_its_lines() { + // CRLF or LF, comment lines and blank lines. + assert_eq!(uri_paths(b"# copied\r\nfile:///home/a/one.svg\r\n\r\nfile:///home/a/two.png\n"), ["/home/a/one.svg", "/home/a/two.png"]); + assert_eq!(uri_paths(b"file:///a.svg"), ["/a.svg"]); + // Percent escapes are decoded once; `+` and an escaped `#` are part of the name. + assert_eq!(uri_paths(b"file:///home/a/red%20image.png"), ["/home/a/red image.png"]); + assert_eq!(uri_paths(b"file:///home/a/caf%C3%A9.svg\nfile:///home/a/caf\xC3\xA9.svg"), ["/home/a/café.svg", "/home/a/café.svg"]); + assert_eq!(uri_paths(b"file:///a/%231+2.svg"), ["/a/#1+2.svg"]); + assert_eq!(uri_paths(b"file:///a/100%2525.svg"), ["/a/100%25.svg"]); + // No host, `localhost` in any case, and the short form name this machine; a fragment is + // left out. + assert_eq!( + uri_paths(b"file://localhost/a.svg\nFILE://LocalHost/b.svg\nfile:/c.svg\nfile:///d.svg#page=2"), + ["/a.svg", "/b.svg", "/c.svg", "/d.svg"] + ); + // Other hosts and schemes, relative paths, queries, bad escapes, an escaped NUL, a name + // that is not UTF-8 once decoded and a line that is not UTF-8 name no file. + for uri in [ + &b"file://server/a.svg"[..], + b"https://example.com/a.svg", + b"file:a.svg", + b"file://", + b"file://localhost", + b"file:///a.svg?x=1", + b"file:///a%2", + b"file:///a%zz.svg", + b"file:///a%00b.svg", + b"file:///a%FF.svg", + b"file:///a\xFF.svg", + b"/home/a/one.svg", + b"#file:///a.svg", + ] { + assert!(uri_list_paths(uri).is_empty(), "{}", String::from_utf8_lossy(uri)); + } + // The files beside a URI that names none are kept. + assert_eq!(uri_paths(b"https://example.com/a.svg\r\nfile:///b.svg\r\n"), ["/b.svg"]); + assert!(uri_list_paths(b"").is_empty()); + } + + #[test] + fn a_uri_list_longer_than_the_limit_names_no_file() { + assert_eq!(MAX_URIS, 1024); + let list = |n: usize| (0..n).map(|i| format!("# file {i}\r\nfile:///f{i}.svg\r\n")).collect::(); + // Comment lines don't count. + assert_eq!(uri_list_paths(list(MAX_URIS).as_bytes()).len(), MAX_URIS); + assert!(uri_list_paths(list(MAX_URIS + 1).as_bytes()).is_empty()); + } + + #[test] + fn gnome_files_list_names_its_files_after_copy_or_cut() { + let paths = |list: &[u8]| gnome_copied_paths(list).into_iter().map(|p| p.to_string_lossy().into_owned()).collect::>(); + assert_eq!(paths(b"copy\nfile:///a.svg\nfile:///b%20c.png"), ["/a.svg", "/b c.png"]); + assert_eq!(paths(b"cut\r\nfile:///a.svg\r\n"), ["/a.svg"]); + // Another first line, none, or no files. + for list in [&b"move\nfile:///a.svg"[..], b"file:///a.svg\nfile:///b.svg", b"copy", b"", b"Copy\nfile:///a.svg"] { + assert!(gnome_copied_paths(list).is_empty(), "{}", String::from_utf8_lossy(list)); + } + } + /// A DIB of `w`×`h` pixels (top row first when `h` is negative): a `header`-byte header (40, /// or 124 for a BITMAPV5HEADER with BGRA masks), `extra` (the masks after a 40-byte header, a /// colour table), then `rows`, each padded to 4 bytes. diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 851afc9e7a..0b3c59ab7b 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -13,6 +13,7 @@ mod calli; mod colorize; mod defaults; +pub mod stored_art; pub mod track; mod warp; @@ -799,9 +800,15 @@ fn number_repeated_names(lib: &mut [Brush]) { } } -/// Store `lib` as the document's brush library. +/// Store `lib` as the document's brush library. When the art of a brush holds images or placed +/// documents, each object in that art whose id is 0 or repeats another object's is given a new +/// id ([`stored_art::StoredArt::persist_ids`]), so the Links panel lists it by the same id until it +/// changes. pub fn store(doc: &mut Document, lib: &[Brush]) { doc.unknown.insert(DOC_KEY.into(), serde_json::to_value(lib).unwrap_or_default()); + if lib.iter().any(|b| b.art().into_iter().any(stored_art::holds_files)) { + stored_art::StoredArt::persist_ids(doc); + } } /// Find a brush by name. diff --git a/crates/brush/src/stored_art.rs b/crates/brush/src/stored_art.rs new file mode 100644 index 0000000000..a386e2e181 --- /dev/null +++ b/crates/brush/src/stored_art.rs @@ -0,0 +1,176 @@ +//! The art of the stored brushes (a scatter or art brush's art, a pattern brush's tiles) as stored +//! art of the document ([`Document::mounted_art`]): set on the document while a command looks it up +//! by id or changes it, then written back into the library entries whose art changed. The Links +//! panel finds the images and placed documents in that art this way. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeKind}; + +use crate::{Brush, BrushKind, DOC_KEY}; + +/// The brushes of a document's stored library whose art holds images or placed documents. +#[derive(Default)] +pub struct StoredArt { + entries: Vec, + /// How many entries the stored library had when it was read. + read_entries: usize, +} + +/// A library entry whose brush's art holds images or placed documents. +struct Entry { + /// Its index in the stored library. + slot: usize, + /// The entry as it was read. + raw: Value, + brush: Brush, + /// The brush's art ([`Brush::art`]), each root with the `owner` the Links panel names for it. + art: Vec<(Arc, Value)>, +} + +/// What names each root of [`Brush::art`] among the brush's art: a pattern brush's tile, in the +/// same order; nothing for the one root of a scatter or art brush. +fn tiles(b: &Brush) -> Vec> { + match &b.kind { + BrushKind::Pattern(p) => std::iter::once(Some("side")) + .chain( + [ + (p.outer_corner.is_some(), "outerCorner"), + (p.inner_corner.is_some(), "innerCorner"), + (p.start.is_some(), "start"), + (p.end.is_some(), "end"), + ] + .into_iter() + .filter(|(held, _)| *held) + .map(|(_, tile)| Some(tile)), + ) + .collect(), + _ => vec![None], + } +} + +/// Whether `n` or its subtree, opacity-mask art included, holds an image or a placed document. +pub(crate) fn holds_files(n: &Node) -> bool { + let mut found = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| found |= matches!(c.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_))); + found +} + +impl StoredArt { + /// The brushes of `doc`'s stored library whose art holds an image or a placed document (opacity + /// masks included). An entry that does not read as a brush is left out, and so is left as it + /// is. A root's owner is + /// `{kind: "brush", name, index, tile?}`, with the name and index the brush has in the library as + /// the Brushes panel lists it ([`crate::library`], a repeated name numbered). + pub fn read(doc: &Document) -> Self { + let Some(stored) = doc.unknown.get(DOC_KEY) else { return Self::default() }; + let Some(list) = stored.as_array() else { return Self::default() }; + let listed = crate::parse_library(stored); + let parsed = list.iter().enumerate().filter_map(|(slot, raw)| Brush::from_json(raw.clone()).ok().map(|b| (slot, raw, b))); + let mut entries = vec![]; + for (index, (slot, raw, brush)) in parsed.enumerate() { + if !brush.art().into_iter().any(holds_files) { + continue; + } + let name = listed.get(index).map_or(brush.name.as_str(), |b| b.name.as_str()); + let art: Vec<(Arc, Value)> = brush + .art() + .into_iter() + .zip(tiles(&brush)) + .map(|(n, tile)| { + let mut owner = json!({ "kind": "brush", "name": name, "index": index }); + if let Some(tile) = tile { + owner["tile"] = json!(tile); + } + (Arc::new(n.clone()), owner) + }) + .collect(); + if !art.is_empty() { + entries.push(Entry { slot, raw: raw.clone(), brush, art }); + } + } + Self { entries, read_entries: list.len() } + } + + /// Gives the objects of the stored brushes' art that holds images or placed documents ids of + /// their own in `doc`'s library, as [`Self::mount`] gives them, and writes the brushes whose + /// ids changed back. Does nothing while art is set on `doc` ([`Document::mounted_art`]). + pub fn persist_ids(doc: &mut Document) { + if !doc.mounted_art.is_empty() { + return; + } + let art = Self::read(doc); + if !art.is_empty() { + art.mount(doc); + art.write_entries(doc); + } + } + + /// No stored brush's art holds an image or a placed document. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Sets the art on `doc` as [`Document::mounted_art`], each object with an id of its own + /// ([`Document::number_mounted_art`]: an id another stored object has, or none, is replaced, and + /// the id counter stands above them all). + pub fn mount(&self, doc: &mut Document) { + doc.mounted_art = self.entries.iter().flat_map(|e| e.art.iter().cloned()).collect(); + doc.number_mounted_art(); + } + + /// Takes [`Document::mounted_art`] off `doc` and writes each brush whose art changed (new ids + /// included) back into its library entry: the entry at its index when that entry is as it was + /// read or reads as the brush as it was apart from its name, else the first entry that reads as + /// the brush as it was, else the first that does apart from its name. An entry found apart from + /// its name keeps that name (the library was written again meanwhile, as placing a document's + /// brushes into it does, numbering a repeated name). The other entries are left as + /// they are. Then the art of brushes added meanwhile gets ids of its own + /// ([`Self::persist_ids`]). + pub fn write_back(self, doc: &mut Document) { + let read_entries = self.read_entries; + self.write_entries(doc); + if doc.unknown.get(DOC_KEY).and_then(Value::as_array).map(Vec::len) != Some(read_entries) { + Self::persist_ids(doc); + } + } + + /// [`Self::write_back`] without giving ids to the art of brushes added meanwhile. + fn write_entries(self, doc: &mut Document) { + let mut mounted = std::mem::take(&mut doc.mounted_art).into_iter(); + let mut changed = vec![]; + for e in self.entries { + let now: Vec> = mounted.by_ref().take(e.art.len()).map(|(n, _)| n).collect(); + if now.len() != e.art.len() || now.iter().zip(&e.art).all(|(a, (b, _))| Arc::ptr_eq(a, b)) { + continue; + } + let mut brush = e.brush.clone(); + for (slot, art) in brush.art_mut().into_iter().zip(now) { + *slot = Arc::unwrap_or_clone(art); + } + changed.push((e.slot, e.raw, e.brush, brush)); + } + let Some(list) = doc.unknown.get_mut(DOC_KEY).and_then(Value::as_array_mut) else { return }; + for (slot, raw, was, mut brush) in changed { + // The entry's name when it reads as `was` apart from its name. + let name_of = |entry: &Value| { + Brush::from_json(entry.clone()).ok().and_then(|mut b| { + let name = std::mem::replace(&mut b.name, was.name.clone()); + (b == was).then_some(name) + }) + }; + let at = match list.get(slot) { + Some(entry) if *entry == raw || name_of(entry).is_some() => Some(slot), + _ => list.iter().position(|e| name_of(e).as_ref() == Some(&was.name)).or_else(|| list.iter().position(|e| name_of(e).is_some())), + }; + let Some(entry) = at.and_then(|i| list.get_mut(i)) else { continue }; + if let Some(name) = name_of(entry) { + brush.name = name; + } + if let Ok(v) = serde_json::to_value(&brush) { + *entry = v; + } + } + } +} diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index ecaba1266a..de178c76ac 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1017,6 +1017,159 @@ fn repeated_brush_names_are_numbered_when_read() { assert_eq!(names.iter().collect::>().len(), names.len()); } +/// Art of `w`×`h` points holding a linked image: a group of [`rect_art`] and the image, both with id 0. +fn linked_art(w: f64, h: f64) -> Node { + let image = Node::new( + NodeId(0), + NodeKind::Image(Box::new(vectorcraft_doc::ImageObject { + key: "k".into(), + width: 4, + height: 4, + xf: Affine::IDENTITY, + link: Some(vectorcraft_doc::LinkInfo::new("/art/a.png")), + placement: Default::default(), + pixels: None, + })), + ); + Node::group(NodeId(0), vec![std::sync::Arc::new(rect_art(w, h)), std::sync::Arc::new(image)]) +} + +/// The art of the stored brushes that holds images mounts on the document as stored art, each tile +/// with the brush, its place in the library and the tile as owner, its objects with ids of their +/// own; writing it back changes only the entries whose art changed, and leaves an entry that does +/// not read as a brush, a brush without art and a brush whose art holds no image as they were stored. +#[test] +fn stored_brush_art_mounts_and_writes_back_only_what_changed() { + use stored_art::StoredArt; + let mut d = Document::new(100.0, 100.0); + let layer_object = d.layers[0].id; + let mut side = linked_art(10.0, 4.0); + side.id = layer_object; + let pattern = + Brush { name: "Frame".into(), kind: BrushKind::Pattern(PatternBrush { side, start: Some(linked_art(4.0, 4.0)), ..Default::default() }) }; + let scatter = Brush { + name: "Dots".into(), + kind: BrushKind::Scatter(Scatter { art: linked_art(2.0, 2.0), colorization: Colorization::None, ..Default::default() }), + }; + // Written as a file holds it, without the ids `store` gives. + let lib = [calli(0.0, 100.0, 3.0), pattern, scatter, art_brush(rect_art(10.0, 4.0), ArtScale::Stretch)]; + d.unknown.insert(DOC_KEY.into(), serde_json::to_value(lib).unwrap()); + let odd = serde_json::json!({ "name": "Odd", "type": "unknown" }); + if let Some(list) = d.unknown.get_mut(DOC_KEY).and_then(serde_json::Value::as_array_mut) { + list.insert(1, odd.clone()); + } + let stored = d.unknown[DOC_KEY].clone(); + let art = StoredArt::read(&d); + art.mount(&mut d); + let owners: Vec<&serde_json::Value> = d.mounted_art.iter().map(|(_, o)| o).collect(); + assert_eq!( + owners, + [ + &serde_json::json!({ "kind": "brush", "name": "Frame", "index": 1, "tile": "side" }), + &serde_json::json!({ "kind": "brush", "name": "Frame", "index": 1, "tile": "start" }), + &serde_json::json!({ "kind": "brush", "name": "Dots", "index": 2 }), + ] + ); + let ids: Vec = d.mounted_art.iter().map(|(n, _)| n.id).collect(); + let mut all = vec![]; + d.visit_stored(|n, _| all.push(n.id)); + assert!(all.iter().all(|id| id.0 != 0) && !all[1..].contains(&layer_object), "{all:?}"); + assert_eq!(all.iter().collect::>().len(), all.len(), "{all:?}"); + // The scatter brush's art changes; the pattern brush's tiles keep the ids mounting gave them. + d.stored_node_mut(ids[2]).unwrap().name = Some("changed".into()); + art.write_back(&mut d); + assert!(d.mounted_art.is_empty()); + let now = d.unknown[DOC_KEY].as_array().unwrap(); + assert_eq!(now.len(), 5); + assert_eq!((&now[0], &now[1], &now[4]), (&stored[0], &odd, &stored[4]), "the entries without images stay as stored"); + assert_ne!(now[2], stored[2], "the pattern brush keeps the ids its art was given"); + let lib = library(&d); + let Some(BrushKind::Pattern(p)) = lib.iter().find(|b| b.name == "Frame").map(|b| &b.kind) else { panic!("no pattern brush") }; + assert_eq!((p.side.id, p.start.as_ref().map(|n| n.id)), (ids[0], Some(ids[1]))); + assert_eq!(find(&d, "Dots").unwrap().art()[0].name.as_deref(), Some("changed")); + // Mounting again gives the same ids and changes nothing, so writing back leaves the library as it is. + let again = d.unknown[DOC_KEY].clone(); + let art = StoredArt::read(&d); + art.mount(&mut d); + assert_eq!(d.mounted_art.iter().map(|(n, _)| n.id).collect::>(), ids); + art.write_back(&mut d); + assert_eq!(d.unknown[DOC_KEY], again); +} + +/// A brush whose stored name repeats an earlier brush's is named in its art's owner as the Brushes +/// panel lists it, numbered. +#[test] +fn stored_brush_art_owner_names_a_repeated_name_as_listed() { + use stored_art::StoredArt; + let mut d = Document::new(100.0, 100.0); + let scatter = Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art: linked_art(2.0, 2.0), ..Default::default() }) }; + store(&mut d, &[scatter.clone(), scatter]); + StoredArt::read(&d).mount(&mut d); + let names: Vec<&serde_json::Value> = d.mounted_art.iter().map(|(_, o)| &o["name"]).collect(); + assert_eq!(names, ["Dots", "Dots 2"]); +} + +/// Storing a library whose brush art holds an image gives that art ids of its own, so mounting it +/// later gives the same ids whatever the document allocated in between; art without images keeps +/// the ids it was stored with. +#[test] +fn storing_brush_art_with_images_keeps_ids_of_its_own() { + use stored_art::StoredArt; + let mut d = Document::new(100.0, 100.0); + let mut art = linked_art(2.0, 2.0); + art.id = d.layers[0].id; + let scatter = Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art, ..Default::default() }) }; + store(&mut d, &[scatter, art_brush(rect_art(10.0, 4.0), ArtScale::Stretch)]); + let lib = library(&d); + let stored = lib[0].art()[0].id; + assert_ne!(stored, d.layers[0].id); + assert_eq!(lib[1].art()[0].id, NodeId(0), "art without images is stored as it was"); + d.alloc_id(); + StoredArt::read(&d).mount(&mut d); + assert_eq!(d.mounted_art[0].0.id, stored); +} + +/// When the library is written again while the art is mounted, numbering a repeated name and +/// adding a brush (as placing a document's brushes into it does), writing back changes the entry +/// at its own place that reads as the brush apart from its new name, keeping that name, even when +/// an earlier brush is the same as the one it was, and gives the added brush's art ids of its own. +#[test] +fn brush_art_written_back_finds_its_renamed_entry_and_numbers_added_art() { + use stored_art::StoredArt; + /// `n` with ids from `next` on, in paint order. + fn numbered(mut n: Node, next: &mut u64) -> Node { + n.id = NodeId(*next); + *next += 1; + for c in n.children_mut().into_iter().flatten() { + *c = std::sync::Arc::new(numbered((**c).clone(), next)); + } + n + } + let mut d = Document::new(100.0, 100.0); + let scatter = + |n: &str| Brush { name: n.into(), kind: BrushKind::Scatter(Scatter { art: numbered(linked_art(2.0, 2.0), &mut 50), ..Default::default() }) }; + // Two brushes the same apart from the ids mounting gives the second. + d.unknown.insert(DOC_KEY.into(), serde_json::to_value([scatter("Dots"), scatter("Dots")]).unwrap()); + let art = StoredArt::read(&d); + art.mount(&mut d); + assert_eq!(d.mounted_art[0].0.id, NodeId(50)); + let second = d.mounted_art[1].0.id; + // The library is written again: the repeated name numbered, a brush whose art repeats an id added. + let mut added = scatter("New"); + if let BrushKind::Scatter(s) = &mut added.kind { + s.art.id = d.layers[0].id; + } + let lib = [library(&d), vec![added]].concat(); + d.unknown.insert(DOC_KEY.into(), serde_json::to_value(&lib).unwrap()); + assert_eq!(lib[1].name, "Dots 2"); + d.stored_node_mut(second).unwrap().name = Some("changed".into()); + art.write_back(&mut d); + let lib = library(&d); + assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::>(), ["Dots", "Dots 2", "New"]); + assert_eq!((lib[0].art()[0].name.as_deref(), lib[1].art()[0].name.as_deref()), (None, Some("changed"))); + assert_ne!(lib[2].art()[0].id, d.layers[0].id, "the added brush's art has ids of its own"); +} + #[test] fn brushes_serde_round_trip() { let lib = defaults().to_vec(); diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index f4297343a6..7fd8707a99 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -9,13 +9,16 @@ //! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles). //! * The **active settings** are process-wide: the working settings (Edit → Color Settings), with //! the profiles [`use_document_profiles`] last put in place of the working spaces; -//! [`crate::Color::to_rgb`] uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`]. +//! [`crate::Color::to_rgb`] uses them. [`with_active`] puts other settings in their place on one +//! thread, as for a document drawn through its own profiles ([`for_document`]). Code that needs +//! deterministic numbers builds its own [`Cms`] with [`Cms::new`]. pub mod display; pub mod generic; pub mod icc; pub mod lab; +use std::cell::{Cell, RefCell}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; @@ -910,9 +913,32 @@ static WORKING: RwLock>> = RwLock::new(None); static RGB_IS_SRGB: AtomicBool = AtomicBool::new(true); static CMYK_IS_DEVICE: AtomicBool = AtomicBool::new(false); +thread_local! { + /// The settings [`with_active`] put in place of the active settings on this thread. + static SCOPED: RefCell>> = const { RefCell::new(None) }; + /// [`SCOPED`]'s flags for the fast paths: [`SCOPED_SET`] while it is set, [`SCOPED_SRGB`] when + /// its RGB space is sRGB, and [`SCOPED_DEVICE`] when its CMYK space is device CMYK. + static SCOPED_FLAGS: Cell = const { Cell::new(0) }; +} +const SCOPED_SET: u8 = 1; +const SCOPED_SRGB: u8 = 2; +const SCOPED_DEVICE: u8 = 4; + /// The active color-management context: the working settings, with the profiles -/// [`use_document_profiles`] last put in place of the working spaces. +/// [`use_document_profiles`] last put in place of the working spaces. Inside [`with_active`] on +/// this thread: the settings it was given. pub fn active() -> Arc { + if SCOPED_FLAGS.with(Cell::get) & SCOPED_SET != 0 + && let Some(c) = SCOPED.with(|s| s.borrow().clone()) + { + return c; + } + installed() +} + +/// The active settings of the process, as [`use_document_profiles`] or [`set_active`] last put them +/// in place. +fn installed() -> Arc { if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() { return c.clone(); } @@ -954,14 +980,8 @@ pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> { /// the active settings changed. pub fn use_document_profiles(rgb: Option<&str>, cmyk: Option<&str>) -> bool { let working = working(); - let mut st = working.settings.clone(); - if let Some(p) = rgb.and_then(profile).filter(|p| p.kind == ProfileKind::Rgb) { - st.rgb = p.name; - } - if let Some(p) = cmyk.and_then(profile).filter(|p| p.kind == ProfileKind::Cmyk) { - st.cmyk = p.name; - } - if st == active().settings { + let st = document_settings(&working.settings, rgb, cmyk); + if st == installed().settings { return false; } if st == working.settings { @@ -976,6 +996,72 @@ pub fn use_document_profiles(rgb: Option<&str>, cmyk: Option<&str>) -> bool { true } +/// The working settings `working` with the profiles a document is assigned (`None`: none) in place +/// of the working spaces, as [`use_document_profiles`] takes them. +fn document_settings(working: &ColorSettings, rgb: Option<&str>, cmyk: Option<&str>) -> ColorSettings { + let mut st = working.clone(); + if let Some(p) = rgb.and_then(profile).filter(|p| p.kind == ProfileKind::Rgb) { + st.rgb = p.name; + } + if let Some(p) = cmyk.and_then(profile).filter(|p| p.kind == ProfileKind::Cmyk) { + st.cmyk = p.name; + } + st +} + +/// How many contexts [`for_document`] keeps. +const DOCUMENT_CONTEXTS: usize = 8; + +/// The color-management context a document assigned the profiles `rgb` and `cmyk` (`None`: none) +/// draws through: the one [`use_document_profiles`] would make active for it. A context that is the +/// active or the working one is that one; the others are kept for the same working settings and +/// loaded profiles, so the same document gets the same context again. A profile that cannot be +/// loaded gives the working settings. +pub fn for_document(rgb: Option<&str>, cmyk: Option<&str>) -> Arc { + static KEPT: Mutex)>> = Mutex::new(Vec::new()); + let working = working(); + let st = document_settings(&working.settings, rgb, cmyk); + let installed = installed(); + if st == installed.settings { + return installed; + } + if st == working.settings { + return working; + } + let generation = profiles_generation(); + let mut kept = KEPT.lock().unwrap_or_else(|e| e.into_inner()); + kept.retain(|(g, c)| *g == generation && c.working.as_ref().is_some_and(|w| Arc::ptr_eq(w, &working))); + if let Some((_, c)) = kept.iter().find(|(_, c)| c.settings == st) { + return c.clone(); + } + let Ok(mut cms) = Cms::new(&st) else { return working }; + cms.working = Some(working); + let cms = Arc::new(cms); + if kept.len() >= DOCUMENT_CONTEXTS { + kept.remove(0); + } + kept.push((generation, cms.clone())); + cms +} + +/// Run `f` with `cms` as the active settings on this thread ([`active`], [`crate::Color::to_rgb`] +/// and the conversions that read them), as when drawing a document other than the front one +/// through its own profiles. Other threads, and this thread after `f`, keep the settings they had. +pub fn with_active(cms: Arc, f: impl FnOnce() -> R) -> R { + /// Puts back the settings this thread had before, when `f` returns or unwinds. + struct Restore(Option>, u8); + impl Drop for Restore { + fn drop(&mut self) { + SCOPED.with(|s| *s.borrow_mut() = self.0.take()); + SCOPED_FLAGS.with(|c| c.set(self.1)); + } + } + let flags = SCOPED_SET | if cms.rgb_is_srgb() { SCOPED_SRGB } else { 0 } | if matches!(cms.cmyk, CmykSpace::Device) { SCOPED_DEVICE } else { 0 }; + let before = SCOPED.with(|s| s.borrow_mut().replace(cms)); + let _restore = Restore(before, SCOPED_FLAGS.with(|c| c.replace(flags))); + f() +} + fn install(cms: Arc) { RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed); CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed); @@ -984,10 +1070,16 @@ fn install(cms: Arc) { /// Fast path for [`Color::to_rgb`]: RGB colours need no conversion while the working space is sRGB. pub(crate) fn rgb_is_srgb() -> bool { - RGB_IS_SRGB.load(Ordering::Relaxed) + match SCOPED_FLAGS.with(Cell::get) { + f if f & SCOPED_SET != 0 => f & SCOPED_SRGB != 0, + _ => RGB_IS_SRGB.load(Ordering::Relaxed), + } } pub(crate) fn cmyk_is_device() -> bool { - CMYK_IS_DEVICE.load(Ordering::Relaxed) + match SCOPED_FLAGS.with(Cell::get) { + f if f & SCOPED_SET != 0 => f & SCOPED_DEVICE != 0, + _ => CMYK_IS_DEVICE.load(Ordering::Relaxed), + } } #[cfg(test)] diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 57709abe66..7b22163525 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -403,3 +403,42 @@ fn lab_colours_round_trip_through_the_cms() { let Color::Gray { k } = c.convert(&Color::lab(50.0, 0.0, 0.0), Model::Gray, ri) else { panic!("Gray") }; assert!((k - 0.53).abs() < 0.01, "{k}"); } + +#[test] +fn with_active_puts_settings_in_place_on_this_thread_only() { + let p3 = Arc::new(Cms::new(&ColorSettings { rgb: DISPLAY_P3.into(), cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap()); + let (rgb, ink) = (Color::Rgb { r: 0.2, g: 0.6, b: 0.4 }, Color::Cmyk { c: 0.2, m: 0.3, y: 0.4, k: 0.1 }); + let before = (rgb.to_rgb(), ink.to_rgb()); + let (inside, ink_inside, other_thread, nested) = with_active(p3.clone(), || { + let other = std::thread::spawn(move || rgb.to_rgb()).join().unwrap(); + let nested = with_active(Arc::new(cms()), || rgb.to_rgb()); + (rgb.to_rgb(), ink.to_rgb(), other, nested) + }); + assert_eq!(inside, p3.display_rgb(&rgb), "RGB is drawn through the settings given"); + assert_ne!(inside, before.0); + assert_eq!(ink_inside, naive_cmyk_to_rgb([0.2, 0.3, 0.4, 0.1]), "CMYK takes the device formula of the settings given"); + assert_eq!(other_thread, before.0, "another thread keeps the active settings"); + assert_eq!(nested, before.0, "an inner call puts its own settings in place"); + assert_eq!((rgb.to_rgb(), ink.to_rgb()), before, "the active settings come back after"); + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| with_active(p3.clone(), || panic!("unwinds")))); + assert!(unwound.is_err()); + assert_eq!((rgb.to_rgb(), ink.to_rgb()), before, "the active settings come back after a panic"); +} + +#[test] +fn a_document_gets_the_same_context_for_the_same_profiles() { + // Other tests load profiles meanwhile, and a load makes the contexts again: try until one pair + // of calls sees none. + let same = (0..100).any(|_| { + let generation = profiles_generation(); + let (a, b) = (for_document(Some(DISPLAY_P3), None), for_document(Some(DISPLAY_P3), None)); + profiles_generation() == generation && Arc::ptr_eq(&a, &b) + }); + assert!(same, "the same profiles give the same context"); + let p3 = for_document(Some(DISPLAY_P3), None); + assert_eq!(p3.settings().rgb, DISPLAY_P3); + assert_eq!(p3.settings().cmyk, working_settings().cmyk, "the working CMYK space stays"); + assert_eq!(for_document(None, None).settings(), &working_settings(), "no profiles: the working settings"); + let unknown = for_document(Some("no such profile"), Some(SRGB)); + assert_eq!(unknown.settings(), &working_settings(), "an unknown profile or one of the other model leaves the working space"); +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 3afca8063c..fa8cd564f2 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -435,9 +435,10 @@ impl Paint { } /// Apply `f` to the colours of this paint that can link to a swatch (a solid colour, each /// gradient stop) with their link and tint; `f` returns true when it changed them. A colour - /// left without a link gets tint 1. A freeform gradient's points, which it draws from, go - /// through `f` too, as unlinked colours (a link `f` gives them is dropped). Returns whether - /// anything changed. + /// left without a link gets tint 1, and a stop left without one also loses its process plates + /// ([`GradientStop::plates`], which print with a linked spot tint). A freeform gradient's + /// points, which it draws from, go through `f` too, as unlinked colours (a link `f` gives them + /// is dropped). Returns whether anything changed. pub fn map_links(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option, &mut f32) -> bool) -> bool { let mut one = |c: &mut Color, link: &mut Option, tint: &mut f32| { let changed = f(c, link, tint); @@ -449,7 +450,11 @@ impl Paint { match self { Paint::Solid { color, swatch, tint } => one(color, swatch, tint), Paint::Gradient(g) => { - let stops = g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch); + let stops = g.gradient.stops.iter_mut().fold(false, |ch, s| { + let changed = one(&mut s.color, &mut s.swatch, &mut s.tint); + let unplated = s.swatch.is_none() && s.plates.take().is_some(); + changed | unplated | ch + }); let points = g.freeform.iter_mut().flat_map(|ff| ff.points.iter_mut()).fold(false, |ch, p| one(&mut p.color, &mut None, &mut 1.0) | ch); stops | points @@ -593,6 +598,34 @@ mod tests { assert!(g.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.5)); } + #[test] + fn mapping_links_drops_the_process_plates_of_unlinked_stops() { + let mut gradient = Gradient::default(); + let ink = Color::cmyk(0.0, 0.4, 0.0, 0.0); + gradient.stops[0].swatch = Some("Ink".into()); + (gradient.stops[0].color, gradient.stops[0].tint, gradient.stops[0].plates) = (ink, 0.4, Some([0.3, 0.0, 0.0, 0.0])); + let mut g = Paint::Gradient(Box::new(GradientPaint::new(gradient))); + // Unlinking the stop takes its plates with its tint and keeps the colour it shows. + assert!(g.map_links(&mut |_, l, _| l.take().is_some())); + let Paint::Gradient(gp) = &g else { panic!("a gradient") }; + let st = &gp.gradient.stops[0]; + assert_eq!((st.color, st.swatch.as_deref(), st.tint, st.plates), (ink, None, 1.0, None)); + // A linked stop keeps its plates. + let mut linked = Gradient::default(); + (linked.stops[0].swatch, linked.stops[0].plates) = (Some("Ink".into()), Some([0.3, 0.0, 0.0, 0.0])); + let mut l = Paint::Gradient(Box::new(GradientPaint::new(linked))); + assert!(!l.map_links(&mut |_, _, _| false)); + let Paint::Gradient(lp) = &l else { panic!("a gradient") }; + assert_eq!(lp.gradient.stops[0].plates, Some([0.3, 0.0, 0.0, 0.0])); + // An unlinked stop that still has plates loses them, and that counts as a change. + let mut stale = Gradient::default(); + stale.stops[1].plates = Some([0.0, 0.0, 0.2, 0.0]); + let mut p = Paint::Gradient(Box::new(GradientPaint::new(stale))); + assert!(p.map_links(&mut |_, _, _| false)); + let Paint::Gradient(pp) = &p else { panic!("a gradient") }; + assert!(pp.gradient.stops.iter().all(|s| s.plates.is_none())); + } + #[test] fn lab_colours_serialize_with_their_tag_and_keep_their_model() { let c = Color::lab(52.5, 70.0, -30.0); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index b84de1cac3..f36993a18c 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -45,7 +45,24 @@ fn lerp_f64(x: f64, y: f64, t: f64) -> f64 { x + (y - x) * t } -/// `like` with every stop painting `c`, linked as the solid is (`swatch` at `tint`). +/// The process plates between two stops linked to one spot swatch: each of C, M, Y and K in turn, +/// a stop without plates counting as none of each. The ends keep their own plates. +fn lerp_plates(a: Option<[f32; 4]>, b: Option<[f32; 4]>, t: f32) -> Option<[f32; 4]> { + if t <= 0.0 { + return a; + } + if t >= 1.0 { + return b; + } + if a.is_none() && b.is_none() { + return None; + } + let (a, b) = (a.unwrap_or_default(), b.unwrap_or_default()); + Some(std::array::from_fn(|i| lerp_f32(a[i], b[i], t))) +} + +/// `like` with every stop painting `c`, linked as the solid is (`swatch` at `tint`), with no +/// process plates (a solid has none). fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) -> Gradient { let mut g = like.clone(); for s in &mut g.stops { @@ -53,16 +70,18 @@ fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) -> s.opacity = 1.0; s.swatch = swatch.map(str::to_string); s.tint = tint; + s.plates = None; } g } -/// The swatch and tint `g` paints at `t`: a stop's own link, or the shared swatch of the two stops -/// around `t` at the tint between theirs (unlinked when they differ), as [`Gradient::sample_with`] walks. -fn link_at(g: &Gradient, t: f32) -> (Option, f32) { +/// The swatch, tint and process plates `g` paints at `t`: a stop's own link, or the shared swatch +/// of the two stops around `t` at the tint and plates between theirs (unlinked when they differ), +/// as [`Gradient::sample_with`] walks. +fn link_at(g: &Gradient, t: f32) -> (Option, f32, Option<[f32; 4]>) { let stops = &g.stops; - let link = |s: &GradientStop| (s.swatch.clone(), s.tint); - let Some(first) = stops.first() else { return (None, 1.0) }; + let link = |s: &GradientStop| (s.swatch.clone(), s.tint, s.plates); + let Some(first) = stops.first() else { return (None, 1.0, None) }; if t <= first.offset { return link(first); } @@ -74,14 +93,14 @@ fn link_at(g: &Gradient, t: f32) -> (Option, f32) { let m = a.midpoint.clamp(0.01, 0.99); let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) }; return match (&a.swatch, &b.swatch) { - (Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(a.tint, b.tint, v)), _ if v <= 0.0 => link(a), _ if v >= 1.0 => link(b), - _ => (None, 1.0), + (Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(a.tint, b.tint, v), lerp_plates(a.plates, b.plates, v)), + _ => (None, 1.0, None), }; } } - stops.last().map_or((None, 1.0), link) + stops.last().map_or((None, 1.0, None), link) } /// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred). @@ -91,8 +110,8 @@ fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient { .iter() .map(|&o| { let (color, opacity) = g.sample_keep_model(o); - let (swatch, tint) = link_at(g, o); - GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) } + let (swatch, tint, plates) = link_at(g, o); + GradientStop { opacity, swatch, tint, plates, ..GradientStop::new(o, color) } }) .collect(); out @@ -139,16 +158,18 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option (Some(x.clone()), lerp_f32(sa.tint, sb.tint, t)), - _ => (None, 1.0), + // Stops on two tints of one swatch stay linked to it, as solids do (#3234), and keep + // the process plates printed with it. + let (swatch, tint, plates) = match (&sa.swatch, &sb.swatch) { + (Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(sa.tint, sb.tint, t), lerp_plates(sa.plates, sb.plates, t)), + _ => (None, 1.0, None), }; GradientStop { opacity: lerp_f32(sa.opacity, sb.opacity, t), midpoint: lerp_f32(sa.midpoint, sb.midpoint, t), swatch, tint, + plates, ..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t)) } }) @@ -2462,6 +2483,112 @@ mod tests { assert!(m.gradient.stops.iter().any(|s| s.swatch.is_none()), "{:?}", m.gradient.stops); } + /// A stop linked to the spot swatch "Spot" at `tint`, printed with process `plates`. + fn spot_stop(offset: f32, tint: f32, plates: Option<[f32; 4]>) -> GradientStop { + let mut stop = GradientStop::new(offset, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint)); + stop.swatch = Some("Spot".into()); + stop.tint = tint; + stop.plates = plates; + stop + } + + fn gradient_paint(stops: Vec) -> Paint { + Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() }))) + } + + fn assert_plates(got: Option<[f32; 4]>, want: [f32; 4]) { + let got = got.unwrap_or_else(|| panic!("plates {want:?}, not none")); + assert!(got.iter().zip(&want).all(|(g, w)| (g - w).abs() < 1e-5), "{got:?} != {want:?}"); + } + + #[test] + fn blend_gradient_spot_process_plates_interpolate() { + let (p, q) = ([0.2, 0.4, 0.0, 0.0], [0.6, 0.0, 0.8, 1.0]); + let a = gradient_paint(vec![spot_stop(0.0, 0.2, Some(p)), spot_stop(1.0, 0.2, Some(p))]); + let b = gradient_paint(vec![spot_stop(0.0, 1.0, Some(q)), spot_stop(1.0, 1.0, Some(q))]); + let Paint::Gradient(m) = lerp_paint(&a, &b, 0.25) else { panic!() }; + for s in &m.gradient.stops { + assert_eq!(s.swatch.as_deref(), Some("Spot")); + assert!((s.tint - 0.4).abs() < 1e-6, "{}", s.tint); + assert_plates(s.plates, [0.3, 0.3, 0.2, 0.25]); + } + // Without plates on either side the steps print the spot ink alone, as before. + let bare = |tint| gradient_paint(vec![spot_stop(0.0, tint, None), spot_stop(1.0, tint, None)]); + let Paint::Gradient(m) = lerp_paint(&bare(0.2), &bare(1.0), 0.5) else { panic!() }; + assert!(m.gradient.stops.iter().all(|s| s.plates.is_none()), "{:?}", m.gradient.stops); + } + + #[test] + fn resampled_gradient_spot_process_plates_interpolate() { + let (p, q, r) = ([0.0, 0.4, 0.0, 0.0], [0.6, 0.4, 0.3, 0.0], [0.2, 0.2, 0.2, 0.2]); + let mut first = spot_stop(0.0, 0.2, Some(p)); + first.midpoint = 0.25; + let two = Gradient { stops: vec![first, spot_stop(1.0, 0.8, Some(q))], ..Gradient::default() }; + let three = + Gradient { stops: vec![spot_stop(0.0, 1.0, Some(r)), spot_stop(0.5, 1.0, Some(r)), spot_stop(1.0, 1.0, Some(r))], ..Gradient::default() }; + let Some((ra, rb)) = common_stops(&two, &three) else { panic!() }; + let offsets: Vec = ra.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.25, 0.5, 1.0]); + // The original stops keep their plates; between them, the midpoint at 0.25 puts 0.5 two + // thirds of the way along the inks. + assert_eq!((ra.stops[0].plates, ra.stops[3].plates), (Some(p), Some(q))); + assert_plates(ra.stops[1].plates, [0.3, 0.4, 0.15, 0.0]); + assert_plates(ra.stops[2].plates, [0.4, 0.4, 0.2, 0.0]); + assert!((ra.stops[2].tint - 0.6).abs() < 1e-5, "{}", ra.stops[2].tint); + assert!(rb.stops.iter().all(|s| s.plates == Some(r)), "{:?}", rb.stops); + + let Paint::Gradient(m) = lerp_paint(&gradient_paint(two.stops), &gradient_paint(three.stops), 0.5) else { panic!() }; + assert_eq!(m.gradient.stops.len(), 4); + assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot")), "{:?}", m.gradient.stops); + assert_plates(m.gradient.stops[2].plates, [0.3, 0.3, 0.2, 0.1]); + } + + #[test] + fn blend_gradient_missing_plates_mean_zero() { + let p = [0.4, 0.8, 0.0, 0.2]; + let with = gradient_paint(vec![spot_stop(0.0, 1.0, Some(p)), spot_stop(1.0, 1.0, Some(p))]); + let without = gradient_paint(vec![spot_stop(0.0, 1.0, None), spot_stop(1.0, 1.0, None)]); + let Paint::Gradient(m) = lerp_paint(&without, &with, 0.25) else { panic!() }; + assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot")), "{:?}", m.gradient.stops); + m.gradient.stops.iter().for_each(|s| assert_plates(s.plates, [0.1, 0.2, 0.0, 0.05])); + // The ends keep their own plates. + let Paint::Gradient(m) = lerp_paint(&without, &with, 0.0) else { panic!() }; + assert!(m.gradient.stops.iter().all(|s| s.plates.is_none()), "{:?}", m.gradient.stops); + + // Another swatch, or an unlinked stop: process steps with no plates, as before. + let mut other = spot_stop(0.0, 1.0, Some(p)); + other.swatch = Some("Other".into()); + let other = gradient_paint(vec![other.clone(), GradientStop { offset: 1.0, ..other }]); + let mut plain = spot_stop(0.0, 1.0, None); + plain.swatch = None; + let plain = gradient_paint(vec![plain.clone(), GradientStop { offset: 1.0, ..plain }]); + for b in [&other, &plain] { + let Paint::Gradient(m) = lerp_paint(&with, b, 0.5) else { panic!() }; + assert!(m.gradient.stops.iter().all(|s| s.swatch.is_none() && s.plates.is_none()), "{:?}", m.gradient.stops); + } + } + + #[test] + fn blend_solid_to_spot_gradient_does_not_copy_plates() { + let p = [0.4, 0.8, 0.0, 0.2]; + let gradient = gradient_paint(vec![spot_stop(0.0, 1.0, Some(p)), spot_stop(1.0, 1.0, Some(p))]); + let Paint::Gradient(g) = &gradient else { panic!() }; + assert!(solid_gradient(Color::BLACK, Some("Spot"), 0.2, &g.gradient).stops.iter().all(|s| s.plates.is_none())); + let solid = Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(0.2), swatch: Some("Spot".into()), tint: 0.2 }; + // The solid's end has no plates: they grow from none toward the gradient's, both ways round. + for (a, b, t) in [(&solid, &gradient, 0.25), (&gradient, &solid, 0.75)] { + let Paint::Gradient(m) = lerp_paint(a, b, t) else { panic!() }; + for s in &m.gradient.stops { + assert_eq!(s.swatch.as_deref(), Some("Spot")); + assert!((s.tint - 0.4).abs() < 1e-6, "{}", s.tint); + assert_plates(s.plates, [0.1, 0.2, 0.0, 0.05]); + } + } + // A process solid unlinks the steps, and they take no plates. + let Paint::Gradient(m) = lerp_paint(&Paint::solid(Color::BLACK), &gradient, 0.5) else { panic!() }; + assert!(m.gradient.stops.iter().all(|s| s.swatch.is_none() && s.plates.is_none()), "{:?}", m.gradient.stops); + } + #[test] fn gradients_with_different_stop_counts_interpolate() { let g = |stops: &[(f32, Color)]| { diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index ad52eea1a2..8cd3f85a4f 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -386,6 +386,21 @@ impl ImageBlob { if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) } } + /// The CMYK ICC profile a CMYK image carries: a four-component JPEG's `ICC_PROFILE` APP2 + /// segments put together, or a CMYK TIFF's tag 34675. `None` for other images, and for a + /// profile that isn't one of CMYK colors. Reads only the file's headers and tags. + pub fn cmyk_icc_profile(&self) -> Option> { + let bytes = self.bytes.as_slice(); + if jpeg_components(bytes) == Some(4) { + return cmyk_icc(jpeg_icc(bytes)); + } + let (kind, mut d) = tiff(bytes)?; + if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { + return None; + } + cmyk_icc(tiff_icc(&mut d)) + } + /// The ink amounts around pixel (`x`, `y`) of a CMYK image, in the pixel space of an image /// object of `object` (width, height) pixels: the average of the `size` × `size` pixels centered /// on it, clipped to the image, as C, M, Y and K bytes. `None` outside the image and for images diff --git a/crates/doc/src/dimension.rs b/crates/doc/src/dimension.rs index 89df899743..e803d867fc 100644 --- a/crates/doc/src/dimension.rs +++ b/crates/doc/src/dimension.rs @@ -815,6 +815,10 @@ impl Dimension { if self.line_between_points() && !old.line_between_points() { self.keep_gap(&old); } + } else if self.mode == DimensionMode::Linear && xf.determinant() < 0.0 && (self.b - self.a).hypot() > 1e-9 { + // A reflection turns the left normal of a → b to the other side, so an aligned + // dimension's line stays on the reflected side of its points. + self.offset = -self.offset; } if self.mode == DimensionMode::Linear { self.length = self.measured(); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 418e454b92..54130788e1 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -79,6 +79,7 @@ impl Document { index: _, guide_cache: _, inline_cycles: _, + mounted_art: _, } = self; *version == other.version && *title == other.title @@ -264,6 +265,7 @@ impl Document { index: Default::default(), guide_cache: Default::default(), inline_cycles: Default::default(), + mounted_art: vec![], }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index 0f1ec463c4..c29e8464ea 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -128,8 +128,8 @@ impl Document { } /// The roots of every tree of the document's art: the layers, symbol definitions, graph designs, - /// pattern swatches and the original of the pattern being edited. Opacity masks hang off nodes - /// in these trees. + /// pattern swatches, the original of the pattern being edited and [`Self::mounted_art`]. + /// Opacity masks hang off nodes in these trees. pub fn art_roots(&self) -> impl Iterator> { self.layers .iter() @@ -137,6 +137,7 @@ impl Document { .chain(self.graph_designs.iter().map(|g| &g.art)) .chain(self.patterns.iter().flat_map(|p| p.art.iter())) .chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter())) + .chain(self.mounted_art.iter().map(|(n, _)| n)) } /// [`Self::art_roots`], to change. @@ -147,6 +148,7 @@ impl Document { .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())) + .chain(self.mounted_art.iter_mut().map(|(n, _)| n)) } /// Does `wants` hold for a node anywhere in the document's art ([`Self::art_roots`]) or its diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index df336fe09e..ba6e4dc428 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -1,6 +1,7 @@ use super::document_ext::free_id; use super::guides::Guide; use super::limits::MAX_NEXT_ID; +use super::node::{held_guides, map_held, strip_held}; use super::{Affine, Arc, Document, Node, NodeId, NodeKind, PatternDef, Point, Rect, Selection, SymbolBoxes, Vec2, artboard_holds}; use std::collections::{BTreeMap, HashSet}; @@ -272,8 +273,8 @@ impl Document { return; } for l in &mut self.layers { - if let Some(held) = picked_in_layers(l, &Node::is_ruler_guide) { - drop_picked(l, held); + if let Some(held) = held_guides(l, &Node::is_ruler_guide) { + strip_held(Arc::make_mut(l), held); } } self.saved_guides.extend(guides); @@ -347,14 +348,14 @@ impl Document { /// saved guide whose layer is gone opens. Returns the removed node, without the guides. pub fn remove_edit_layer(&mut self, id: NodeId) -> Result, crate::DocError> { let mut layer = self.remove(id)?; - let Some(held) = picked_in_layers(&layer, &Node::is_ruler_guide) else { return Ok(layer) }; + let Some(held) = held_guides(&layer, &Node::is_ruler_guide) else { return Ok(layer) }; let mut guides = vec![]; layer.walk(&mut |c| { if c.is_ruler_guide() { guides.push(Arc::new(c.clone())); } }); - drop_picked(&mut layer, held); + strip_held(Arc::make_mut(&mut layer), held); let home = self.default_layer().unwrap_or_else(|| self.add_layer(None)); self.children_mut(Some(home))?.extend(guides); Ok(layer) @@ -418,8 +419,8 @@ impl Document { } let pick = |n: &Node| n.is_ruler_guide() && gone.contains(&n.id); for l in &mut self.layers { - if let Some(held) = picked_in_layers(l, &pick) { - drop_picked(l, held); + if let Some(held) = held_guides(l, &pick) { + strip_held(Arc::make_mut(l), held); } } sel.guides.retain(|id| !gone.contains(id)); @@ -434,8 +435,8 @@ impl Document { fn edit_artboard_guides(&mut self, id: u32, f: &mut impl FnMut(bool, &mut f64)) { let pick = |n: &Node| matches!(n.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id); for l in &mut self.layers { - if let Some(held) = picked_in_layers(l, &pick) { - edit_picked(l, held, f); + if let Some(held) = held_guides(l, &pick) { + map_held(Arc::make_mut(l), held, f); } } } @@ -569,57 +570,6 @@ impl Picked { } } -/// The children of `n`, if it is a layer, that `pick` picks (with nothing inside), and its -/// sublayers holding one (with what they hold), or `None` when it holds none. `pick` looks at -/// each child of the layers once. -fn picked_in_layers(n: &Node, pick: &impl Fn(&Node) -> bool) -> Option { - if !n.is_layer() { - return None; - } - let children = n - .children() - .into_iter() - .flatten() - .enumerate() - .filter_map(|(i, c)| if pick(c) { Some((i, Picked::default())) } else { picked_in_layers(c, pick).map(|p| (i, p)) }) - .collect(); - Picked { children, mask: None }.some() -} - -/// Remove the children of `layer` and of its sublayers that [`picked_in_layers`] picked (`held`). -/// Only the layers holding one are copied, and only those are visited. -fn drop_picked(layer: &mut Arc, held: Picked) { - let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; - let mut held = held.children.into_iter().peekable(); - let mut next = 0; - ch.retain_mut(|c| { - let at = next; - next += 1; - let Some((_, inner)) = held.next_if(|(i, _)| *i == at) else { return true }; - // A sublayer holds something; a child picked itself has nothing inside. - if inner.is_empty() { - return false; - } - drop_picked(c, inner); - true - }); -} - -/// Change with `f` (whether it is vertical, its position) the line of each ruler guide of `layer` -/// and of its sublayers that [`picked_in_layers`] picked (`held`), in the order they lie in. Only -/// the layers holding one, and the guides, are copied, and only those layers are visited. -fn edit_picked(layer: &mut Arc, held: Picked, f: &mut impl FnMut(bool, &mut f64)) { - let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; - for (i, inner) in held.children { - let Some(c) = ch.get_mut(i) else { continue }; - if !inner.is_empty() { - edit_picked(c, inner, f); - } else if let NodeKind::RulerGuide { vertical, pos, .. } = &mut Arc::make_mut(c).kind { - f(*vertical, pos); - } - } -} - /// `n` picked itself when it is a ruler guide by `is_guide`, else the ruler guides below it /// ([`guides_below`]). fn guide_or_below(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option { @@ -872,9 +822,9 @@ mod tests { looked.set(looked.get() + 1); on_artboard_7(n) }; - let held = picked_in_layers(&d.layers[0], &pick).unwrap(); + let held = held_guides(&d.layers[0], &pick).unwrap(); assert_eq!(looked.get(), NESTED_CHILDREN, "each child of the layers is looked at once"); - drop_picked(&mut d.layers[0], held); + strip_held(Arc::make_mut(&mut d.layers[0]), held); assert_eq!(looked.get(), NESTED_CHILDREN, "removing looks at nothing again"); assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still)), "a sublayer with nothing to remove is not copied"); @@ -932,10 +882,10 @@ mod tests { looked.set(looked.get() + 1); on_artboard_7(n) }; - let held = picked_in_layers(&d.layers[0], &pick).unwrap(); + let held = held_guides(&d.layers[0], &pick).unwrap(); assert_eq!(looked.get(), NESTED_CHILDREN, "each child of the layers is looked at once"); let mut seen = vec![]; - edit_picked(&mut d.layers[0], held, &mut |vertical, pos: &mut f64| { + map_held(Arc::make_mut(&mut d.layers[0]), held, &mut |vertical, pos: &mut f64| { seen.push((vertical, *pos)); *pos += 9.0; }); diff --git a/crates/doc/src/inks.rs b/crates/doc/src/inks.rs index e10c424ee9..a9fa5f3f80 100644 --- a/crates/doc/src/inks.rs +++ b/crates/doc/src/inks.rs @@ -94,25 +94,45 @@ pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) Inks { cmyk: c.to_cmyk(color, intent), spot: None, registration: false } } -/// Visits one colour: the colour, its swatch link and its tint, all of which it may change. -pub type ColorVisitor<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32) + 'a; +/// Process plates a colour prints together with its spot tint (C, M, Y, K, 0..1): a gradient +/// stop's [`vectorcraft_color::GradientStop::plates`]; `None` for every other colour. +pub type StopPlates = Option<[f32; 4]>; + +/// [`inks`] of a colour that may print process plates `plates` with its spot tint. The plates +/// print only beside a spot ink: a colour without a valid spot link, or linked to Registration, +/// prints as [`inks`] separates it. A plate amount that is not a finite number prints no ink. +pub fn stop_inks(doc: &Document, c: &Cms, color: &Color, link: Link, plates: StopPlates, intent: Intent) -> Inks { + let mut ink = inks(doc, c, color, link, intent); + if let (Some(p), Some(_)) = (plates, &ink.spot) { + ink.cmyk = p.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }); + } + ink +} + +/// Visits one colour: the colour, its swatch link, its tint and its process plates +/// ([`StopPlates`]), all of which it may change. +pub type ColorVisitor<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32, &mut StopPlates) + 'a; fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) { match p { - Paint::Solid { color, swatch, tint } => f(color, swatch, tint), + Paint::Solid { color, swatch, tint } => f(color, swatch, tint, &mut None), Paint::Gradient(g) => { for s in &mut g.gradient.stops { - f(&mut s.color, &mut s.swatch, &mut s.tint); + f(&mut s.color, &mut s.swatch, &mut s.tint, &mut s.plates); + } + for p in g.freeform.iter_mut().flat_map(|ff| ff.points.iter_mut()) { + f(&mut p.color, &mut None, &mut 1.0, &mut None); } } _ => {} } } -/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, an -/// image's colorize color, the fills and strokes a dynamic symbol instance keeps for objects of its -/// art, not opacity masks) with its swatch link and tint. Mesh points have no link: changes to it -/// are dropped. +/// Visit every colour of a node tree (fills, strokes, gradient stops, a freeform gradient's +/// points, text runs, mesh points, an image's colorize color, the fills and strokes a dynamic +/// symbol instance keeps for objects of its art, not opacity masks) with its swatch link, tint and +/// process plates. Freeform and mesh points have no link or plates: a link or plates `f` gives +/// them are dropped. pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { for it in &mut n.appearance.items { match it { @@ -137,7 +157,7 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { } NodeKind::Mesh(m) => { for p in &mut m.points { - f(&mut p.color, &mut None, &mut 1.0); + f(&mut p.color, &mut None, &mut 1.0, &mut None); } } _ => {} @@ -149,11 +169,16 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { } } -/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh -/// points, a dynamic symbol instance's own fills and strokes) given its swatch link and tint, -/// keeping the links. +/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, a freeform +/// gradient's points, text runs, mesh points, a dynamic symbol instance's own fills and strokes) +/// given its swatch link and tint, keeping the links and process plates. pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) { - visit_node_colors(n, &mut |c, swatch, tint| *c = f(c, swatch.as_deref().map(|s| (s, *tint)))); + map_node_inks(n, &mut |c, link, _| f(c, link)); +} + +/// [`map_node_colors`], with each colour's process plates ([`StopPlates`]) passed to `f` too. +pub fn map_node_inks(n: &mut Node, f: &mut dyn FnMut(&Color, Link, StopPlates) -> Color) { + visit_node_colors(n, &mut |c, swatch, tint, plates| *c = f(c, swatch.as_deref().map(|s| (s, *tint)), *plates)); } /// Apply `f` to every colour in the document's art and symbol definitions. @@ -170,8 +195,70 @@ pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) - mod tests { use super::*; use crate::{NodeId, SymbolOverride}; + use vectorcraft_color::{Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, GradientStop, Swatch}; use vectorcraft_geom::Affine; + /// A document with spot swatch `Gold` and process swatch `Teal`. + fn gold_doc() -> Document { + let mut d = Document::new(100.0, 100.0); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true }); + d.swatches.push(Swatch { name: "Teal".into(), paint: Paint::solid(Color::cmyk(0.8, 0.0, 0.3, 0.0)), global: true, spot: false }); + d + } + + #[test] + fn a_spot_stops_process_plates_print_with_its_tint() { + let d = gold_doc(); + let c = vectorcraft_color::cms::active(); + let look = Color::cmyk(0.25, 0.1, 0.8, 0.05); + let ink = |link: Link, plates: StopPlates| stop_inks(&d, &c, &look, link, plates, Intent::RelativeColorimetric); + let mixed = ink(Some(("Gold", 0.5)), Some([0.25, 0.0, 0.4, 0.0])); + assert_eq!(mixed, Inks { cmyk: [0.25, 0.0, 0.4, 0.0], spot: Some(("Gold".into(), 0.5)), registration: false }); + assert_eq!((mixed.on_plate("Cyan"), mixed.on_plate("Gold")), (0.25, 0.5)); + // Amounts outside 0..1 are clamped; one that is not a number prints no ink. + let odd = ink(Some(("Gold", 1.0)), Some([f32::NAN, 2.0, -1.0, f32::INFINITY])); + assert_eq!(odd.cmyk, [0.0, 1.0, 0.0, 0.0]); + // Without plates the spot ink prints alone, as `inks` separates it. + assert_eq!(ink(Some(("Gold", 0.5)), None), inks(&d, &c, &look, Some(("Gold", 0.5)), Intent::RelativeColorimetric)); + // Plates beside no spot ink print nothing of their own: a process swatch, an unknown + // swatch, no link and Registration separate as `inks` does. + for link in [Some(("Teal", 1.0)), Some(("Missing", 1.0)), None, Some((REGISTRATION, 0.5))] { + assert_eq!(ink(link, Some([0.25, 0.0, 0.4, 0.0])), inks(&d, &c, &look, link, Intent::RelativeColorimetric), "{link:?}"); + } + } + + #[test] + fn gradient_stops_pass_their_plates_and_freeform_points_are_visited() { + let mut stop = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05)); + (stop.swatch, stop.tint, stop.plates) = (Some("Gold".into()), 0.5, Some([0.25, 0.0, 0.4, 0.0])); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![stop, GradientStop::new(1.0, Color::rgb(1.0, 1.0, 1.0))])); + let point = FreeformPoint::new(kurbo::Point::new(10.0, 10.0), Color::rgb(0.0, 1.0, 0.0)); + g.freeform = Some(Freeform { points: vec![point], ..Default::default() }); + let mut n = Node::path( + NodeId(1), + vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)), + crate::Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0), + ); + let mut seen = vec![]; + let gray = Color::Gray { k: 0.5 }; + map_node_inks(&mut n, &mut |c, link, plates| { + seen.push((*c, link.map(|(s, t)| (s.to_string(), t)), plates)); + gray + }); + assert_eq!( + seen, + vec![ + (Color::cmyk(0.25, 0.1, 0.8, 0.05), Some(("Gold".to_string(), 0.5)), Some([0.25, 0.0, 0.4, 0.0])), + (Color::rgb(1.0, 1.0, 1.0), None, None), + (Color::rgb(0.0, 1.0, 0.0), None, None), + ] + ); + let Some(Paint::Gradient(g)) = n.appearance.fill().map(|f| f.paint.clone()) else { panic!("no gradient fill") }; + assert_eq!(g.gradient.stops[0].plates, Some([0.25, 0.0, 0.4, 0.0]), "mapping a colour keeps its plates"); + assert!(g.gradient.stops.iter().all(|s| s.color == gray)); + assert_eq!(g.freeform.map(|f| f.points[0].color), Some(gray), "the freeform point is mapped too"); + } + #[test] fn a_dynamic_instances_own_fills_and_strokes_are_visited() { let red = Color::rgb(1.0, 0.0, 0.0); diff --git a/crates/doc/src/inline.rs b/crates/doc/src/inline.rs index a8e3955015..86c26e14f4 100644 --- a/crates/doc/src/inline.rs +++ b/crates/doc/src/inline.rs @@ -8,13 +8,20 @@ use std::sync::{Arc, RwLock, Weak}; use vectorcraft_geom::{Affine, Rect}; -use crate::{Document, Node, NodeId, NodeKind, Symbol, TextObject}; +use crate::symbols::SymbolNames; +use crate::{Document, Node, NodeId, NodeKind, Symbol, SymbolBoxes, TextObject}; /// The document key under which the app records each symbol's natural size (`{name: [w, h]}`). pub const SYMBOL_SIZES: &str = "symbolSizes"; /// Half the side of the square symbol art is normalised to. pub const SYMBOL_HALF: f64 = 10.0; +/// The box symbol art `art` is measured by in [`Document::symbol_art_bounds`]: its visual bounds, +/// or its geometric bounds when it has no visual bounds. +fn art_extent(art: &Node, boxes: &SymbolBoxes) -> Option { + art.visual_bounds_with(boxes).or_else(|| art.geometric_bounds_with(boxes)) +} + /// Usable art bounds: finite, non-degenerate. fn usable(r: Option) -> Option { r.filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) && r.width() >= 0.0 && r.height() > 1e-9) @@ -35,27 +42,38 @@ impl Document { } /// Visual bounds of the art of the symbol named `name` at its natural size (see - /// [`Self::symbol_natural_xf`]), if it exists and has a usable extent. + /// [`Self::symbol_natural_xf`]), if it exists and has a usable extent. The symbol instances in + /// the art are measured by their own symbols' art ([`Node::visual_bounds_with`]), or by the + /// 20 × 20 square where [`crate::SymbolBoxes`] measures them by it. pub fn symbol_art_bounds(&self, name: &str) -> Option { - let s = self.symbols.iter().find(|s| s.name == name)?; - let b = usable(s.art.visual_bounds().or_else(|| s.art.geometric_bounds()))?; + self.symbol_art_bounds_in(name, &self.symbol_boxes()) + } + + /// [`Self::symbol_art_bounds`], with the symbols found and the instances measured by `boxes` + /// (new boxes of this document). + fn symbol_art_bounds_in(&self, name: &str, boxes: &SymbolBoxes) -> Option { + let b = usable(art_extent(boxes.symbol_art(name)?, boxes))?; usable(Some(self.symbol_natural_xf(name).transform_rect_bbox(b))) } /// The art bounds an inline graphic of the symbol named `name` takes: [`Self::symbol_art_bounds`], /// or `None` (an empty slot) when the symbol's art shows the symbol itself inline, directly or - /// through the inline graphics in other symbols' art, so that its size would depend on itself. + /// through the inline graphics and symbol instances in other symbols' art, so that its size + /// would depend on itself. pub fn inline_art_bounds(&self, name: &str) -> Option { - if self.inline_cycles().contains(name) { None } else { self.symbol_art_bounds(name) } + let (cyclic, names) = self.inline_cycles(); + if cyclic.contains(name) { None } else { self.symbol_art_bounds_in(name, &self.symbol_boxes().with_names(&names)) } } - /// The names of the symbols that reach themselves through the inline graphics in symbol art, - /// found once while the symbols stay as they are. - fn inline_cycles(&self) -> Arc> { + /// The names of the symbols that reach themselves through the inline graphics in symbol art + /// ([`InlineGraph`]), with each symbol name → the index of the first symbol of that name, found + /// once while the symbols stay as they are. + fn inline_cycles(&self) -> (Arc>, Arc) { if let Some(found) = self.inline_cycles.get(&self.symbols) { return found; } - let found = Arc::new(InlineGraph::of(self).cyclic); + let graph = InlineGraph::of(self); + let found = (Arc::new(graph.cyclic), graph.names); self.inline_cycles.set(&self.symbols, &found); found } @@ -94,7 +112,8 @@ impl Document { /// of symbols, graph designs and pattern tiles, and the opacity masks and live objects in /// them. Each text whose inline graphics changed has its cached layout cleared and is passed /// to `layout` to be laid out again. The symbols are resolved first, each after the symbols its - /// inline graphics show, so a symbol's art is measured with its type already laid out; a + /// inline graphics and symbol instances show, so a symbol's art is measured with its type, and + /// the type in the art of the symbols its instances show, already laid out; a /// symbol that shows itself inline ([`Self::inline_art_bounds`]) is shown as an empty slot. /// Returns the ids of the texts in the layers that changed. pub fn resolve_inline_art_with(&mut self, layout: &mut dyn FnMut(&mut TextObject)) -> Vec { @@ -241,34 +260,48 @@ fn inline_symbols(n: &Node) -> Vec { out } -/// Each symbol name of `d` → the index of the first symbol of that name (the one a name finds). -fn symbol_index(d: &Document) -> HashMap<&str, usize> { - let mut index = HashMap::with_capacity(d.symbols.len()); - for (i, s) in d.symbols.iter().enumerate() { - index.entry(s.name.as_str()).or_insert(i); - } - index -} - -/// The symbols of a document as their inline graphics depend on each other. +/// The symbols of a document as their sizes depend on each other: a symbol's art depends on the +/// symbols its type shows inline, and on the symbols its instances show when those instances are +/// measured by their symbols' art ([`crate::SymbolBoxes`]). struct InlineGraph { - /// Every symbol index, each after the symbols its art shows inline (except along a cycle). + /// Every symbol index, each after the symbols its art depends on (except along a cycle). order: Vec, - /// The names of the symbols that reach themselves through inline graphics. + /// The names of the symbols that reach themselves through inline graphics. Instances alone make + /// no cycle: an instance of a symbol that holds an instance of itself is measured by the + /// 20 × 20 square, so every cycle passes through an inline graphic. cyclic: HashSet, + /// Each symbol name → the index of the first symbol of that name, which stay the same while + /// [`Document::resolve_inline_art_with`] changes the symbols' art. + names: Arc, } impl InlineGraph { - /// The graph of `d`'s symbols: each symbol's art is walked once, and the order and cycles are - /// found in time linear in the symbols and their inline references (Tarjan's strongly - /// connected components, without recursion). + /// The graph of `d`'s symbols: each symbol's art is walked once, and, when the art of any + /// symbol holds an inline graphic, measured once with the art boxes of the symbols its + /// instances show measured once in all ([`SymbolBoxes::consulted`]: an instance that does not + /// size the art, such as one a clipping path hides, adds nothing). The order and cycles are + /// found in time linear in the symbols and their inline references and instances (Tarjan's + /// strongly connected components, without recursion). fn of(d: &Document) -> Self { - let index = symbol_index(d); + let names = Arc::new(d.symbol_names()); + let boxes = d.symbol_boxes().quick().with_names(&names); + let inline: Vec> = d.symbols.iter().map(|s| inline_symbols(&s.art)).collect(); + // Without inline graphics in symbol art, no symbol's type depends on another symbol. + let any_inline = inline.iter().any(|v| !v.is_empty()); let edges: Vec> = d .symbols .iter() - .map(|s| { - let mut e: Vec = inline_symbols(&s.art).iter().filter_map(|n| index.get(n.as_str()).copied()).collect(); + .zip(inline) + .map(|(s, inline)| { + let measured = if any_inline { + boxes.consulted(|| { + art_extent(&s.art, &boxes); + }) + } else { + HashSet::new() + }; + let shows = inline.into_iter().chain(measured); + let mut e: Vec = shows.filter_map(|n| names.get(n.as_str()).copied()).collect(); e.sort_unstable(); e.dedup(); e @@ -276,12 +309,13 @@ impl InlineGraph { .collect(); let (order, cyclic) = components(&edges); let cyclic = order.iter().filter(|&&i| cyclic[i]).map(|&i| d.symbols[i].name.clone()).collect(); - Self { order, cyclic } + Self { order, cyclic, names } } - /// [`Document::inline_art_bounds`] with the cycles found in this graph. + /// [`Document::inline_art_bounds`] with the cycles found in this graph, for `d` with the + /// symbols' names and order the graph was made with. fn bounds(&self, d: &Document, name: &str) -> Option { - if self.cyclic.contains(name) { None } else { d.symbol_art_bounds(name) } + if self.cyclic.contains(name) { None } else { d.symbol_art_bounds_in(name, &d.symbol_boxes().with_names(&self.names)) } } } @@ -354,12 +388,16 @@ fn components(edges: &[Vec]) -> (Vec, Vec) { #[derive(Default)] pub(crate) struct InlineCycles(RwLock>>); -/// The name and art of each symbol the cycles were found in, and the names of the symbols on one. -type Found = (Vec<(String, Weak)>, Arc>); +/// The names of the symbols on a cycle, and each symbol name → the index of the first symbol of that +/// name. +type Cycles = (Arc>, Arc); + +/// The name and art of each symbol the cycles were found in, and what was found. +type Found = (Vec<(String, Weak)>, Cycles); impl InlineCycles { /// The cached names, when they were found in `symbols`. - fn get(&self, symbols: &[Symbol]) -> Option>> { + fn get(&self, symbols: &[Symbol]) -> Option { let g = self.0.read().ok()?; let (from, found) = &**g.as_ref()?; let same = from.len() == symbols.len() @@ -368,7 +406,7 @@ impl InlineCycles { } /// Keep `found`, found in `symbols`. - fn set(&self, symbols: &[Symbol], found: &Arc>) { + fn set(&self, symbols: &[Symbol], found: &Cycles) { if let Ok(mut g) = self.0.write() { *g = Some(Arc::new((symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect(), found.clone()))); } @@ -485,6 +523,109 @@ mod tests { assert_eq!(count.get(), 2); } + /// A rectangle `r` filled black, with a 2 pt black stroke. + fn stroked(d: &mut Document, r: Rect) -> Arc { + use vectorcraft_color::{Color, Paint}; + let look = crate::Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 2.0); + Arc::new(Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), look)) + } + + fn instance(d: &mut Document, symbol: &str, xf: Affine) -> Arc { + Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf })) + } + + /// Symbol `name` holding `art` in a group. + fn symbol(d: &mut Document, name: &str, art: Vec>) { + let group = Node::group(d.alloc_id(), art); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(group) }); + } + + #[test] + fn a_symbol_nested_in_symbol_art_is_measured_by_its_own_art() { + // `Far` is a rectangle from (200, 100) to (240, 130) with a 2 pt stroke, far from the + // 20 × 20 square around the origin. + let mut d = Document::new(500.0, 500.0); + let far = stroked(&mut d, Rect::new(200.0, 100.0, 240.0, 130.0)); + symbol(&mut d, "Far", vec![far]); + let moved = instance(&mut d, "Far", Affine::translate((-100.0, 0.0))); + symbol(&mut d, "Moved", vec![moved.clone()]); + let scaled = instance(&mut d, "Far", Affine::scale(2.0)); + symbol(&mut d, "Scaled", vec![scaled]); + symbol(&mut d, "Sized", vec![moved]); + d.unknown.insert(SYMBOL_SIZES.into(), serde_json::json!({ "Sized": [40.0, 20.0] })); + assert_eq!(d.symbol_art_bounds("Far"), Some(Rect::new(199.0, 99.0, 241.0, 131.0))); + // The stroke paints 1 pt around the moved rectangle. + assert_eq!(d.symbol_art_bounds("Moved"), Some(Rect::new(99.0, 99.0, 141.0, 131.0))); + // Scaled 2 times, the stroke keeps its weight. + assert_eq!(d.symbol_art_bounds("Scaled"), Some(Rect::new(399.0, 199.0, 481.0, 261.0))); + // At its natural size, 40 × 20 for the 20 × 20 square: 2 times across. + assert_eq!(d.symbol_art_bounds("Sized"), Some(Rect::new(198.0, 99.0, 282.0, 131.0))); + assert_eq!(d.inline_art_bounds("Moved"), d.symbol_art_bounds("Moved")); + } + + #[test] + fn nested_instances_without_art_of_their_own_are_measured_by_the_square() { + let mut d = Document::new(500.0, 500.0); + let r = Rect::new(0.0, 0.0, 10.0, 10.0); + // An instance of a missing symbol and one of a symbol with no art, beside a rectangle. + symbol(&mut d, "Empty", vec![]); + let pieces = vec![stroked(&mut d, r), instance(&mut d, "Nowhere", Affine::translate((100.0, 0.0)))]; + symbol(&mut d, "Missing", pieces); + let pieces = vec![stroked(&mut d, r), instance(&mut d, "Empty", Affine::translate((100.0, 0.0)))]; + symbol(&mut d, "Holds empty", pieces); + let with_square = Some(Rect::new(-1.0, -10.0, 110.0, 11.0)); + assert_eq!(d.symbol_art_bounds("Empty"), None); + assert_eq!(d.symbol_art_bounds("Missing"), with_square); + assert_eq!(d.symbol_art_bounds("Holds empty"), with_square); + // A symbol that holds an instance of itself: the instance is measured by the square, and + // an inline graphic of it is not an empty slot. + let pieces = vec![stroked(&mut d, r), instance(&mut d, "Loop", Affine::translate((100.0, 0.0)))]; + symbol(&mut d, "Loop", pieces); + assert_eq!(d.symbol_art_bounds("Loop"), with_square); + assert_eq!(d.inline_art_bounds("Loop"), with_square); + // A chain of 40 symbols, each holding a rectangle and an instance of the next one 1 pt to + // the right: symbols nest 39 deep in Link 0's art, past the limit of 32, so its instance of + // Link 1 is measured by the square. + for i in 0..40 { + let unit = stroked(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0)); + let next = instance(&mut d, &format!("Link {}", i + 1), Affine::translate((1.0, 0.0))); + symbol(&mut d, &format!("Link {i}"), if i < 39 { vec![unit, next] } else { vec![unit] }); + } + assert_eq!(d.symbol_art_bounds("Link 0"), Some(Rect::new(-9.0, -10.0, 11.0, 10.0))); + // Link 8's art nests 31 deep: measured by the art of the links after it. + assert_eq!(d.symbol_art_bounds("Link 8"), Some(Rect::new(-1.0, -1.0, 33.0, 2.0))); + } + + /// Type showing the symbol named `symbol` inline. + fn showing(d: &mut Document, symbol: &str) -> Arc { + let style = crate::CharStyle::default(); + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Type", style.clone()); + t.runs.push(crate::TextRun::new("", style)); + t.runs[1].inline = Some(crate::InlineArt { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None }); + Arc::new(Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))) + } + + #[test] + fn only_instances_that_size_a_symbols_art_make_it_depend_on_their_symbol() { + // B's type shows A inline. A's art is a clip group: a clipping rectangle, then an instance + // of B that the rectangle clips, so A's size does not depend on B's. + let mut d = Document::new(500.0, 500.0); + let text = showing(&mut d, "A"); + symbol(&mut d, "B", vec![text]); + let clip = stroked(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0)); + let inner = instance(&mut d, "B", Affine::IDENTITY); + let group = Arc::new(Node::new(d.alloc_id(), NodeKind::Group { children: vec![clip, inner.clone()], clip: true })); + symbol(&mut d, "A", vec![group]); + assert_eq!(d.inline_art_bounds("A"), Some(Rect::new(-1.0, -1.0, 11.0, 11.0))); + // Without the clip, A's size depends on B's and B's on A's: both are empty slots. + let art = Arc::new(Node::group(d.alloc_id(), vec![inner])); + if let Some(a) = d.symbols.iter_mut().find(|s| s.name == "A") { + a.art = art; + } + assert_eq!(d.inline_art_bounds("A"), None); + assert_eq!(d.inline_art_bounds("B"), None); + } + #[test] fn components_handle_a_long_chain_without_recursion() { let n = 200_000; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c51e099840..cb78d96ba9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -367,6 +367,12 @@ pub struct Document { /// saved. #[serde(skip)] inline_cycles: inline::InlineCycles, + /// Art kept elsewhere in the document's data (the art of the stored brushes, which + /// `vectorcraft-brush` reads from [`Self::unknown`]) while it is set here, each root with the + /// `owner` the Links panel names for it, so the stored-art lookups ([`StoredRoot::Mounted`]) + /// find its objects too. Empty otherwise; never saved. + #[serde(skip)] + pub mounted_art: Vec<(Arc, serde_json::Value)>, } fn ppi72() -> f64 { diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index ffa9eac0cd..b2f46700eb 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -248,14 +248,15 @@ fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { impl Document { /// Visit every image object: in the layers, then in symbol definitions and pattern swatches, - /// opacity-mask art included. + /// then in [`Document::mounted_art`], opacity-mask art included. pub fn visit_images<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a ImageObject)) { let roots = self .layers .iter() .chain(self.symbols.iter().map(|s| &s.art)) .chain(self.graph_designs.iter().map(|g| &g.art)) - .chain(self.patterns.iter().flat_map(|p| &p.art)); + .chain(self.patterns.iter().flat_map(|p| &p.art)) + .chain(self.mounted_art.iter().map(|(n, _)| n)); for root in roots { walk_all(root, &mut |n| { if let NodeKind::Image(im) = &n.kind { @@ -384,7 +385,8 @@ impl Align { impl Document { /// Rewrite linked file locations in both raster images and placed VectorCraft documents, - /// including those inside symbols, patterns and opacity masks. Used to make packages portable. + /// including those inside symbols, patterns, opacity masks and [`Document::mounted_art`]. Used to + /// make packages portable. pub fn update_links(&mut self, mut f: impl FnMut(&mut LinkInfo)) { fn walk(n: &mut Arc, f: &mut dyn FnMut(&mut LinkInfo)) { let n = Arc::make_mut(n); @@ -409,7 +411,8 @@ impl Document { .iter_mut() .chain(self.symbols.iter_mut().map(|s| &mut s.art)) .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) - .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); + .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)) + .chain(self.mounted_art.iter_mut().map(|(n, _)| n)); for root in roots { walk(root, &mut f); } @@ -471,7 +474,8 @@ impl Document { .iter_mut() .chain(self.symbols.iter_mut().map(|s| &mut s.art)) .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) - .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); + .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)) + .chain(self.mounted_art.iter_mut().map(|(n, _)| n)); for root in roots { update(root, &pick, &mut f); } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index eeec6ab162..79ce594212 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -32,6 +32,7 @@ pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_b pub use live_shape::{LiveShape, Scaling}; pub use opacity::{Knockout, OpacityMask, whiten}; pub use ops::{flattens, keeps_gradient_fit}; +pub(crate) use ops::{held_guides, map_held, strip_held}; use wire::NodeWire; pub(crate) use wire::counted_list; pub use wire::{MAX_READ_NODES, with_node_budget}; diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index fb6e8de8d9..efb006afed 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -823,11 +823,11 @@ impl Node { /// The ruler guides a layer holds: its children that are one, by index in order (each with /// nothing inside), and its sublayers holding one (with what they hold). #[derive(Debug, Default)] -pub(super) struct HeldGuides(Vec<(usize, HeldGuides)>); +pub(crate) struct HeldGuides(Vec<(usize, HeldGuides)>); /// The ruler guides, by `is_guide`, that `n` holds if it is a layer, or `None` when it holds /// none. `is_guide` looks at each child of `n` and of its sublayers once. -pub(super) fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option { +pub(crate) fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option { if !n.is_layer() { return None; } @@ -843,7 +843,7 @@ pub(super) fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option /// Remove the ruler guides of `layer` and of its sublayers that [`held_guides`] found (`held`). /// Only the sublayers holding one are copied, and only those are visited. -pub(super) fn strip_held(layer: &mut Node, held: HeldGuides) { +pub(crate) fn strip_held(layer: &mut Node, held: HeldGuides) { let Some(ch) = layer.children_mut() else { return }; let mut held = held.0.into_iter().peekable(); let mut next = 0; @@ -863,7 +863,7 @@ pub(super) fn strip_held(layer: &mut Node, held: HeldGuides) { /// Call `f` (whether it is vertical, its position) on each ruler guide of `layer` and of its /// sublayers that [`held_guides`] found (`held`), in the order they lie in. Only the sublayers /// holding one, and the guides, are copied, and only those sublayers are visited. -pub(super) fn map_held(layer: &mut Node, held: HeldGuides, f: &mut impl FnMut(bool, &mut f64)) { +pub(crate) fn map_held(layer: &mut Node, held: HeldGuides, f: &mut impl FnMut(bool, &mut f64)) { let Some(ch) = layer.children_mut() else { return }; for (i, inner) in held.0 { let Some(c) = ch.get_mut(i) else { continue }; diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 86f7f6134c..cd423658d7 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -166,13 +166,15 @@ fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { } } -/// The roots of `d`'s art: layers, symbol definitions and pattern swatches. +/// The roots of `d`'s art: layers, symbol definitions, pattern swatches and +/// [`Document::mounted_art`]. fn roots(d: &Document) -> impl Iterator> { d.layers .iter() .chain(d.symbols.iter().map(|s| &s.art)) .chain(d.graph_designs.iter().map(|g| &g.art)) .chain(d.patterns.iter().flat_map(|p| &p.art)) + .chain(d.mounted_art.iter().map(|(n, _)| n)) } fn roots_mut(d: &mut Document) -> impl Iterator> { @@ -181,6 +183,7 @@ fn roots_mut(d: &mut Document) -> impl Iterator> { .chain(d.symbols.iter_mut().map(|s| &mut s.art)) .chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) .chain(d.patterns.iter_mut().flat_map(|p| &mut p.art)) + .chain(d.mounted_art.iter_mut().map(|(n, _)| n)) } impl Document { diff --git a/crates/doc/src/stored_art.rs b/crates/doc/src/stored_art.rs index e623e0ed0e..cc0832b733 100644 --- a/crates/doc/src/stored_art.rs +++ b/crates/doc/src/stored_art.rs @@ -20,6 +20,8 @@ pub enum StoredRoot { GraphDesign(usize), /// Object `art` of pattern swatch `pattern` of [`Document::patterns`]. Pattern { pattern: usize, art: usize }, + /// Root `index` of [`Document::mounted_art`]. + Mounted(usize), } /// Where a stored object is: its root and, inside opacity-mask art, the object that mask belongs @@ -170,10 +172,12 @@ impl Document { .iter() .enumerate() .flat_map(|(p, def)| def.art.iter().enumerate().map(move |(a, n)| (n, StoredRoot::Pattern { pattern: p, art: a }))); - symbols.chain(designs).chain(patterns) + let mounted = self.mounted_art.iter().enumerate().map(|(i, (n, _))| (n, StoredRoot::Mounted(i))); + symbols.chain(designs).chain(patterns).chain(mounted) } - /// Every root of stored art: the layers, then the art of symbols, graph designs and patterns. + /// Every root of stored art: the layers, then the art of symbols, graph designs and patterns, + /// then [`Document::mounted_art`]. fn stored_roots(&self) -> impl Iterator, StoredRoot)> { self.layers.iter().enumerate().map(|(i, l)| (l, StoredRoot::Layer(i))).chain(self.definition_roots()) } @@ -185,12 +189,13 @@ impl Document { StoredRoot::Symbol(i) => self.symbols.get_mut(i).map(|s| &mut s.art), StoredRoot::GraphDesign(i) => self.graph_designs.get_mut(i).map(|g| &mut g.art), StoredRoot::Pattern { pattern, art } => self.patterns.get_mut(pattern)?.art.get_mut(art), + StoredRoot::Mounted(i) => self.mounted_art.get_mut(i).map(|(n, _)| n), } } /// Visit every stored object with its place: the layer tree with the opacity-mask art of its /// objects, then the art of symbols, graph designs and pattern swatches (their definitions, - /// once each, never the copies instances and tiles draw). + /// once each, never the copies instances and tiles draw), then [`Document::mounted_art`]. pub fn visit_stored<'a>(&'a self, mut f: impl FnMut(&'a Node, StoredPlace)) { self.visit_stored_arcs(|n, place| f(n, place)); } @@ -238,6 +243,29 @@ impl Document { /// visited before them (the layer tree's own objects first) a new id, so every stored object /// can be named by its id. Returns how many changed; the id counter then stands above them. pub(crate) fn renumber_stored_duplicates(&mut self) -> usize { + self.renumber_duplicates_in(|_| true) + } + + /// Raises the id counter above the ids of [`Document::mounted_art`]'s objects, then gives those + /// objects whose id is 0, past the id range, or already taken by an object visited before them + /// (every other stored object first) a new id, as [`Self::renumber_stored_duplicates`] gives + /// them. Returns how many changed. + pub fn number_mounted_art(&mut self) -> usize { + let mut max = 0; + for (i, (root, _)) in self.mounted_art.iter().enumerate() { + visit(root, StoredPlace { root: StoredRoot::Mounted(i), mask_of: None }, &mut |n, _| { + if n.id.0 < MAX_NEXT_ID { + max = max.max(n.id.0); + } + }); + } + self.raise_next_id(max.saturating_add(1)); + self.renumber_duplicates_in(|r| matches!(r, StoredRoot::Mounted(_))) + } + + /// [`Self::renumber_stored_duplicates`] for the roots `pick` holds for only; the objects of the + /// others still count as taken. + fn renumber_duplicates_in(&mut self, pick: impl Fn(StoredRoot) -> bool) -> usize { let mut seen: HashSet = HashSet::new(); for l in &self.layers { l.walk(&mut |n| { @@ -250,9 +278,9 @@ impl Document { // Per root, the visiting indices of the nodes that take a new id. let mut fresh: Vec<(StoredRoot, HashSet)> = vec![]; for (root, r) in self.stored_roots() { - let (mut i, mut here) = (0, HashSet::new()); + let (mut i, mut here, take) = (0, HashSet::new(), pick(r)); visit(root, StoredPlace { root: r, mask_of: None }, &mut |n, place| { - if !place.in_layers() && (n.id.0 == 0 || n.id.0 >= MAX_NEXT_ID || !seen.insert(n.id)) { + if !place.in_layers() && (n.id.0 == 0 || n.id.0 >= MAX_NEXT_ID || !seen.insert(n.id)) && take { here.insert(i); } i += 1; @@ -355,6 +383,30 @@ mod tests { assert_eq!(d, again); } + /// Mounted art is found and changed by id after the other stored art, and numbering it gives + /// its objects that repeat another stored object's id, or have none, new ids above the counter, + /// leaving every other object's id and its own unique ones as they are. + #[test] + fn mounted_art_is_found_and_numbered_after_the_rest() { + let mut d = doc([12, 13, 14]); + let owner = serde_json::json!({ "kind": "brush" }); + d.mounted_art = vec![(Arc::new(masked(10, rect(0))), owner.clone()), (Arc::new(rect(15)), owner)]; + assert_eq!(d.stored_node(NodeId(15)).map(|(_, p)| p.root), Some(StoredRoot::Mounted(1))); + let before = d.clone(); + assert_eq!(d.number_mounted_art(), 2); + let mut ids = vec![]; + d.visit_stored(|n, _| ids.push(n.id.0)); + let unique: HashSet = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); + assert_eq!(&ids[..6], &[d.layers[0].id.0, 10, 11, 12, 13, 14], "the other art keeps its ids"); + assert_eq!(ids[8], 15, "a unique id is kept"); + assert!(ids.iter().all(|id| *id < d.peek_next_id()), "{ids:?} {}", d.peek_next_id()); + assert!(Arc::ptr_eq(&d.symbols[0].art, &before.symbols[0].art) && Arc::ptr_eq(&d.mounted_art[1].0, &before.mounted_art[1].0)); + d.stored_node_mut(NodeId(15)).unwrap().name = Some("changed".into()); + assert_eq!(d.mounted_art[1].0.name.as_deref(), Some("changed")); + assert_eq!(d.number_mounted_art(), 0, "a second pass changes nothing"); + } + #[test] fn renumbered_objects_on_path_keep_their_path_and_members() { // A symbol's Objects on Path group whose path repeats the layer's object 10 and whose diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index dde6621993..822dce49ea 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -2,7 +2,7 @@ //! the document and measures it once, and an instance's bounds are its art's box placed by the //! instance's transform ([`Node::geometric_bounds_with`]). -use std::cell::{Cell, RefCell}; +use std::cell::{Cell, OnceCell, RefCell}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -26,6 +26,9 @@ const MAX_REACHES: usize = 4096; /// A symbol's name and the bits of the linear part of a transform that places its art. type Placing = (String, [u64; 4]); +/// Each symbol name of a document → the index of the first symbol of that name. +pub(crate) type SymbolNames = HashMap; + /// A symbol's art measured in symbol space. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct ArtBox { @@ -51,6 +54,10 @@ pub(crate) struct ArtBox { /// symbols change. pub struct SymbolBoxes<'a> { doc: &'a Document, + /// Each symbol name of the document → the index of the first symbol of that name (the one a + /// name finds), made the first time a name is looked up ([`Self::symbol_art`]) or given by + /// [`Self::with_names`]. + names: OnceCell>, /// The art boxes measured so far, by symbol name (`None` for a symbol whose instances are /// measured by [`SYMBOL_SQUARE`]). measured: RefCell>>, @@ -89,6 +96,9 @@ pub struct SymbolBoxes<'a> { sliced_most: u64, /// The widths [`Self::measured_width`] has measured, by kind and symbol name. widths: RefCell>>, + /// While [`Self::consulted`] runs: the symbols whose art boxes [`Self::art`] has given outside + /// the measuring of symbol art. + consulted: RefCell>>, } impl Document { @@ -96,6 +106,7 @@ impl Document { pub fn symbol_boxes(&self) -> SymbolBoxes<'_> { SymbolBoxes { doc: self, + names: OnceCell::new(), measured: RefCell::default(), depths: RefCell::default(), quick: Cell::new(false), @@ -110,8 +121,19 @@ impl Document { sliced_placed: Cell::new(0), sliced_most: MAX_SYMBOL_NODES, widths: RefCell::default(), + consulted: RefCell::default(), } } + + /// Each symbol name of this document → the index of the first symbol of that name (the one a + /// name finds). + pub(crate) fn symbol_names(&self) -> SymbolNames { + let mut names = HashMap::with_capacity(self.symbols.len()); + for (i, s) in self.symbols.iter().enumerate() { + names.entry(s.name.clone()).or_insert(i); + } + names + } } impl<'a> SymbolBoxes<'a> { @@ -120,6 +142,26 @@ impl<'a> SymbolBoxes<'a> { self.doc } + /// Makes these boxes find symbols by `names` ([`Document::symbol_names`]), made from the + /// document's symbols while they had the names and order they have now. + pub(crate) fn with_names(self, names: &Arc) -> Self { + let _ = self.names.set(names.clone()); + self + } + + /// The art of the first symbol of the document named `name`, found in time independent of the + /// number of symbols after the first lookup. + pub(crate) fn symbol_art(&self, name: &str) -> Option<&'a Arc> { + let doc: &'a Document = self.doc; + let names = self.names.get_or_init(|| Arc::new(doc.symbol_names())); + let i = *names.get(name)?; + match doc.symbols.get(i) { + Some(s) if s.name == name => Some(&s.art), + // Names that no longer match the symbols: the first symbol of that name. + _ => doc.symbols.iter().find(|s| s.name == name).map(|s| &s.art), + } + } + /// Makes these boxes measure every symbol instance as [`Node::reach_bounds_with`] does, by its /// art box alone. An instance that scales or turns stroked art then measures a little larger /// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it. The @@ -160,21 +202,37 @@ impl<'a> SymbolBoxes<'a> { /// art boxes. Returns `None` when [`Self::depth`] is `None` and when the art has no finite /// bounds: instances of the symbol are then measured by [`SYMBOL_SQUARE`]. pub(crate) fn art(&self, name: &str) -> Option { - if let Some(b) = self.measured.borrow().get(name) { - return *b; + let cached = self.measured.borrow().get(name).copied(); + let b = cached.unwrap_or_else(|| { + let b = self.measure_art(name, |art| self.in_art(|| art_box(art, self))).flatten(); + self.measured.borrow_mut().insert(name.to_string(), b); + b + }); + if b.is_some() + && let Some(seen) = self.consulted.borrow_mut().as_mut() + && !seen.contains(name) + { + seen.insert(name.to_string()); } - let b = self.measure_art(name, |art| self.in_art(|| art_box(art, self))).flatten(); - self.measured.borrow_mut().insert(name.to_string(), b); b } + /// Runs `measure`, and returns the names of the symbols whose art boxes it took from these + /// boxes, the art of symbols it measured left out: the symbols whose art the instances it + /// measured are measured by. + pub(crate) fn consulted(&self, measure: impl FnOnce()) -> HashSet { + let was = self.consulted.replace(Some(HashSet::new())); + measure(); + self.consulted.replace(was).unwrap_or_default() + } + /// Runs `measure` on the art of the symbol named `name`. Returns `None` when [`Self::depth`] /// is `None`, without running it. pub fn measure_art(&self, name: &str, measure: impl FnOnce(&Arc) -> R) -> Option { self.depth(name)?; - let sym = self.doc.symbols.iter().find(|s| s.name == name)?; + let art = self.symbol_art(name)?; self.runs.set(self.runs.get() + 1); - Some(measure(&sym.art)) + Some(measure(art)) } /// How many times [`Self::measure_art`] has run a measure on symbol art for these boxes, the @@ -297,10 +355,9 @@ impl<'a> SymbolBoxes<'a> { /// other symbols it reaches: the depth of each one it walks to the end, and `None` for those on /// the way down to a symbol that holds an instance of itself or nests too deep. fn find_depth(&self, name: &str, learned: &mut HashMap>) -> Option { - let art = |n: &str| self.doc.symbols.iter().find(|s| s.name == n).map(|s| &s.art); // The symbols on the way down from `name`: each with the symbols its art shows that are // still to be walked, and the deepest nesting found under it so far. - let mut path: Vec<(String, Vec, usize)> = vec![(name.to_string(), shown(self.doc, art(name)?), 0)]; + let mut path: Vec<(String, Vec, usize)> = vec![(name.to_string(), self.shown(self.symbol_art(name)?), 0)]; loop { let (_, todo, _) = path.last_mut()?; let Some(next) = todo.pop() else { @@ -330,8 +387,8 @@ impl<'a> SymbolBoxes<'a> { // Symbols nested MAX_DEPTH deep under `name`. None if path.len() >= MAX_DEPTH => return None, None => { - if let Some(a) = art(&next) { - let todo = shown(self.doc, a); + if let Some(a) = self.symbol_art(&next) { + let todo = self.shown(a); path.push((next, todo, 0)); } } @@ -379,7 +436,7 @@ impl<'a> SymbolBoxes<'a> { None => { // The most objects placing makes, the art of opacity masks included, is set aside // while the art is placed and measured, and what placing did not make is given back. - let art = &self.doc.symbols.iter().find(|s| s.name == name)?.art; + let art = self.symbol_art(name)?; let most = match self.doc.symbol_slice_map(name, linear) { Some(m) => m.most_placed(art, self.doc), None => art.count_with_masks() as u64, @@ -431,11 +488,28 @@ impl<'a> SymbolBoxes<'a> { fn in_art(&self, f: impl FnOnce() -> R) -> R { let pad = self.brush_art.replace(false); let strokes = self.art_strokes.replace(true); + let consulted = self.consulted.take(); let r = self.quickly(f); self.brush_art.set(pad); self.art_strokes.set(strokes); + *self.consulted.borrow_mut() = consulted; r } + + /// The names of the symbols of the document that the instances in `art` show, each once. + fn shown(&self, art: &Node) -> Vec { + let mut seen = HashSet::new(); + let mut names = vec![]; + art.walk(&mut |n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && seen.insert(symbol.as_str()) + && self.symbol_art(symbol).is_some() + { + names.push(symbol.clone()); + } + }); + names + } } /// Records every symbol on `path` in `learned` as one whose instances are measured by @@ -447,21 +521,6 @@ fn give_up(path: &[(String, Vec, usize)], learned: &mut HashMap Vec { - let mut seen = HashSet::new(); - let mut names = vec![]; - art.walk(&mut |n| { - if let NodeKind::SymbolInstance { symbol, .. } = &n.kind - && seen.insert(symbol.as_str()) - && doc.symbols.iter().any(|s| s.name == *symbol) - { - names.push(symbol.clone()); - } - }); - names -} - /// Measures symbol art `art`, with the instances in it measured by `symbols`. fn art_box(art: &Arc, symbols: &SymbolBoxes) -> Option { let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()); diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 27d3194eb3..f75490557d 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -201,6 +201,9 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { { return bake_leaf(d, n); } + if let Some(m) = clip_group_with_type_clip(d, n) { + return Some(m); + } let mut m = n.clone(); // A clipping path takes the geometry effects in it as the canvas clips by it, those of a // compound path's paths before the compound path's own ([`crate::clip::with_clip_effects_applied`]); @@ -226,6 +229,62 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { bake_leaf(d, n) } +/// Clip group or clipping layer `n` whose clipping path is type with geometry effects (its own, its +/// fills' or strokes', or a perspective), baked as the canvas draws it: the type becomes a +/// clipping path of the region the canvas clips to ([`crate::clip_outline`]), its fills (with their +/// effects baked) paint behind the clipped art inside the clip, and its strokes paint over it +/// outside the clip, in a group that `n` becomes with its clip group inside ([`Node::clip_paint`]). +/// The other members are baked as usual. `None` for other objects and when the type has nothing to +/// clip by. +fn clip_group_with_type_clip(d: &mut Document, n: &Node) -> Option { + let (first, rest) = n.children()?.split_first()?; + if !n.clips() + || !matches!(first.kind, NodeKind::Text(_)) + || !(has_geometry(&first.appearance.effects) || first.projection().is_some() || has_item_geometry(first)) + { + return None; + } + let (bp, rule) = crate::clip_outline(first)?; + let mut clip = Node::path(first.id, PathData::from_bezpath(&bp), Appearance::default()); + if let NodeKind::Path { rule: r, clipping, .. } = &mut clip.kind { + (*r, *clipping) = (rule, true); + } + clip.name = first.name.clone(); + clip.visible = first.visible; + let paint = first.clip_paint(); + // The type's fills and strokes as art: baked with the type's id, as the renderer draws them, + // then given ids of their own. + let mut seen = std::collections::HashSet::from([n.id, first.id]); + let mut art = |d: &mut Document, part: Option| { + part.map(|p| { + let mut a = bake_node(d, &p).unwrap_or(p); + fresh_ids(d, &mut a, &mut seen); + Arc::new(a) + }) + }; + let fill = art(d, paint.fill); + let stroke = art(d, paint.stroke); + let members: Vec> = + std::iter::once(Arc::new(clip)).chain(fill).chain(rest.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone()))).collect(); + let mut m = n.clone(); + let Some(stroke) = stroke else { + if let Some(ch) = m.children_mut() { + *ch = members; + } + return Some(m); + }; + // `n` keeps its id, name and transparency, and holds the clip group and the strokes over it. + let inner = Node::new(d.alloc_id(), NodeKind::Group { children: members, clip: true }); + match &mut m.kind { + NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } => { + *children = vec![Arc::new(inner), stroke]; + *clip = false; + } + _ => return None, + } + Some(m) +} + /// `m`, art evaluated from an object, with its children baked and then given ids of their own (the /// pieces copied from the source keep the source's ids until then). The pieces are baked with the /// ids the renderer draws them with: Random in a Transform effect takes its seed from the id. diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index d2ba694149..8181f0e02e 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -2,7 +2,7 @@ use super::*; use serde_json::json; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; -use vectorcraft_geom::{Point, Rect, shapes}; +use vectorcraft_geom::{Point, Rect, Shape, shapes}; /// Type tests need glyph outlines, which only the optional craft-fonts build input provides. fn skip_without_craft_fonts() -> bool { @@ -813,6 +813,56 @@ fn type_in_a_clipping_group_with_a_fill_of_its_own_keeps_its_effect() { assert!(b.y0 > 330.0 && b.y1 < 370.0, "{b:?}"); } +#[test] +fn clipping_type_whose_fill_has_a_geometry_effect_clips_by_its_plain_outlines() { + if skip_without_craft_fonts() { + return; + } + let near = |a: Rect, b: Rect, tol: f64| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < tol); + for stroke in [Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0))] { + // Type that clips a square, with a Transform effect on its own fill. + let mut t = type_node(60); + t.appearance = Appearance::basic(Paint::solid(Color::BLACK), stroke.clone(), 1.0).into(); + for item in &mut t.appearance.items { + if let AppearanceItem::Fill(f) = item { + f.effects.push(fx("distort.transform", json!({"moveH": 300}))); + } + } + let square = Node::path(NodeId(61), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + let clip = Node::new(NodeId(62), NodeKind::Group { children: vec![Arc::new(t.clone()), Arc::new(square)], clip: true }); + let canvas = crate::clip_outline(&t).map(|(bp, _)| bp.bounding_box()).unwrap(); + assert!(canvas.x1 < 100.0, "the canvas clips by the plain outlines: {canvas:?}"); + let d = bake_document(&doc_with(clip)).unwrap(); + let baked = d.node(NodeId(62)).unwrap(); + // With a stroke, the clip group sits in a group with the stroke over it, outside the clip. + let group = match baked.kind { + NodeKind::Group { clip: true, .. } => { + assert!(stroke.is_none(), "{baked:?}"); + baked + } + _ => { + let ch = baked.children().unwrap(); + assert_eq!(ch.len(), 2, "{baked:?}"); + let strokes = ch[1].appearance.items.iter().all(|i| matches!(i, AppearanceItem::Stroke(_))); + assert!(matches!(ch[1].kind, NodeKind::Text(_)) && strokes, "the type with only its stroke: {:?}", ch[1]); + &ch[0] + } + }; + let ch = group.children().unwrap(); + assert!(matches!(group.kind, NodeKind::Group { clip: true, .. }) && ch.len() == 3, "{group:?}"); + // The export clips by the outlines the canvas clips by. + assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0]); + let b = crate::clip_outline(&ch[0]).map(|(bp, _)| bp.bounding_box()).unwrap(); + assert!(near(b, canvas, 1e-6), "canvas {canvas:?}, export {b:?}"); + // The fills (the characters' and the moved one) paint behind the square, inside the clip. + let fill = ch[1].geometric_bounds().unwrap(); + assert!((fill.x0 - canvas.x0).abs() < 1e-6 && fill.x1 > canvas.x1 + 250.0, "{fill:?}"); + assert_eq!(ch[2].id, NodeId(61)); + let mut ids = std::collections::HashSet::new(); + baked.walk(&mut |n| assert!(ids.insert(n.id), "duplicate id {}", n.id)); + } +} + #[test] fn type_with_its_own_transform_effect_copies_stays_type() { if skip_without_craft_fonts() { diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 0db50ef925..20b4f982a3 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -741,35 +741,93 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)], Some(g) } -/// Whether a sublayer among the members of `n`, or among its groups' members, holds a ruler guide. -fn sublayer_guides(n: &Node) -> bool { - n.children().is_some_and(|ch| ch.iter().any(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c)))) +/// The members of a container that [`take_held_guides`] visits, by index, each with the same for +/// its own members. A ruler guide's entry is empty; a container's is not. +#[derive(Default)] +struct HeldGuides(Vec<(usize, HeldGuides)>); + +/// The members of `n` holding or leading to the ruler guides of sublayers, found by looking at each +/// member at every depth once: the ruler guides (`is_guide`) of `n` when it is a layer, each +/// sublayer holding one directly or in its sublayers, and each member of another kind with such a +/// sublayer among its members or its groups' members. With them, whether `n` is a layer holding a +/// ruler guide directly or in its sublayers. The other members of a layer holding none are not +/// looked into, unless the layer is the one whose sublayers are gathered (`top`). +fn held_guides(n: &Node, top: bool, is_guide: &impl Fn(&Node) -> bool) -> (bool, HeldGuides) { + let layer = n.is_layer(); + let (mut holds, mut held, mut other) = (false, vec![], vec![]); + for (i, c) in n.children().into_iter().flatten().enumerate() { + if is_guide(c) { + if layer { + holds = true; + held.push((i, HeldGuides::default())); + } + } else if c.is_layer() { + let (sub_holds, inner) = held_guides(c, false, is_guide); + if sub_holds { + holds = true; + held.push((i, inner)); + } + } else { + other.push((i, c)); + } + } + if !layer || holds || top { + for (i, c) in other { + let (_, inner) = held_guides(c, false, is_guide); + if !inner.0.is_empty() { + held.push((i, inner)); + } + } + held.sort_unstable_by_key(|(i, _)| *i); + } + (layer && holds, HeldGuides(held)) +} + +/// Move the ruler guides of `n` and of its members that [`held_guides`] found (`held`) to `out`: +/// those of `n` first, in order, then those of each member in turn. A guide taken from a hidden or +/// locked container, or from one inside such a container, is hidden or locked itself. Only the +/// containers in `held` are copied, and only those are visited. +fn take_held_guides(n: &mut Node, held: HeldGuides, out: &mut Vec>, hidden: bool, locked: bool) { + let Some(ch) = n.children_mut() else { return }; + let (mut own, mut deeper) = (vec![], vec![]); + for (i, inner) in held.0 { + if inner.0.is_empty() { + own.push(i); + continue; + } + let Some(c) = ch.get_mut(i) else { continue }; + let c = Arc::make_mut(c); + let (hidden, locked) = (hidden || !c.visible, locked || c.locked); + take_held_guides(c, inner, &mut deeper, hidden, locked); + } + if !own.is_empty() { + let mut own = own.into_iter().peekable(); + for (i, mut c) in std::mem::take(ch).into_iter().enumerate() { + if own.next_if_eq(&i).is_none() { + ch.push(c); + continue; + } + if hidden || locked { + let g = Arc::make_mut(&mut c); + g.visible &= !hidden; + g.locked |= locked; + } + out.push(c); + } + } + out.append(&mut deeper); } /// Move the ruler guides of the sublayers among the members of `n`, and among its groups' -/// members, to `out`, in order. A guide taken from a hidden or locked sublayer, or from one inside -/// such a sublayer or group, is hidden or locked itself. Only containers holding one are copied. -fn take_sublayer_guides(n: &mut Node, out: &mut Vec>, hidden: bool, locked: bool) { - let Some(ch) = n.children_mut() else { return }; - for c in ch.iter_mut().filter(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c))) { - let c = Arc::make_mut(c); - let (hidden, locked) = (hidden || !c.visible, locked || c.locked); - if c.is_layer() - && let Some(lc) = c.children_mut() - { - let (taken, art): (Vec<_>, Vec<_>) = std::mem::take(lc).into_iter().partition(|g| g.is_ruler_guide()); - *lc = art; - out.extend(taken.into_iter().map(|mut g| { - if hidden || locked { - let g = Arc::make_mut(&mut g); - g.visible &= !hidden; - g.locked |= locked; - } - g - })); - } - take_sublayer_guides(c, out, hidden, locked); - } +/// members, to `out`, in order: a sublayer's own guides, then those of its members. A guide taken +/// from a hidden or locked sublayer, or from one inside such a sublayer or group, is hidden or +/// locked itself. Each member at every depth is looked at once at most (`is_guide`), and only +/// containers holding one are copied. +fn take_sublayer_guides(n: &mut Node, out: &mut Vec>, is_guide: &impl Fn(&Node) -> bool) { + let (_, mut held) = held_guides(n, true, is_guide); + // The ruler guides of `n` itself stay where they are. + held.0.retain(|(_, inner)| !inner.0.is_empty()); + take_held_guides(n, held, out, false, false); } /// `n`, a group or layer, as plain art: its Pathfinder effects evaluated, its geometry effects @@ -797,7 +855,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { // So do its sublayers' guides, which would otherwise go with the art the effects replace or // copy: they stay on this layer, hidden and locked as their sublayers were. if m.is_layer() && (pathfinder || !fx.is_empty()) { - take_sublayer_guides(&mut m, &mut guides, false, false); + take_sublayer_guides(&mut m, &mut guides, &Node::is_ruler_guide); } if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 947bf262f5..877528d0bb 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -1760,6 +1760,91 @@ fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked() } } +/// Gathering the ruler guides of nested sublayers, and of a sublayer inside nested groups, looks +/// at each member at every depth once at most, and takes the same guides, in the same order and +/// state, as a layer's effects keep. +#[test] +fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { + use std::cell::Cell; + use vectorcraft_doc::LayerColor; + const CHAIN: u64 = 6; + let guide = |id| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: true, pos: id as f64, artboard: None })); + let layer = |id, children: Vec>| { + let mut l = Node::layer(NodeId(id), "Sub", LayerColor::Preset(1)); + if let Some(ch) = l.children_mut() { + *ch = children; + } + l + }; + // Sublayers nested CHAIN deep, each holding the next before its own art and guide. The second + // is hidden and the fourth locked. + let first_guide = guide(201); + let mut chain = layer(100 + CHAIN, vec![sq(300 + CHAIN, 0.0, Color::BLACK), guide(200 + CHAIN)]); + for depth in (1..CHAIN).rev() { + let own = if depth == 1 { first_guide.clone() } else { guide(200 + depth) }; + chain = layer(100 + depth, vec![Arc::new(chain), sq(300 + depth, 0.0, Color::BLACK), own]); + (chain.visible, chain.locked) = (depth != 2, depth == 4); + } + let second_guide = chain.children().and_then(|c| c[0].children()).map(|c| c[2].clone()).unwrap(); + // A sublayer inside five nested groups, the third of them locked. + let mut groups = layer(450, vec![guide(250), sq(350, 0.0, Color::BLACK)]); + for depth in (1..=5).rev() { + groups = Node::group(NodeId(400 + depth), vec![Arc::new(groups)]); + groups.locked = depth == 3; + } + let free = Arc::new(Node::group(NodeId(500), vec![sq(501, 0.0, Color::BLACK), sq(502, 5.0, Color::BLACK)])); + // A sublayer with no guide: the art in its group is not looked at. + let quiet = Node::group(NodeId(601), vec![sq(602, 0.0, Color::BLACK), sq(603, 5.0, Color::BLACK)]); + let quiet = Arc::new(layer(600, vec![Arc::new(quiet)])); + let mut root = layer(10, vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(chain), Arc::new(groups), free.clone(), quiet.clone(), guide(20)]); + fn members(n: &Node) -> usize { + n.children().into_iter().flatten().map(|c| 1 + members(c)).sum() + } + let expected: Vec<_> = (1..=CHAIN).map(|depth| (NodeId(200 + depth), depth < 2, depth >= 4)).chain([(NodeId(250), true, true)]).collect(); + let state = |g: &[Arc]| g.iter().map(|g| (g.id, g.visible, g.locked)).collect::>(); + + let mut m = root.clone(); + let looked = Cell::new(0_usize); + let is_guide = |n: &Node| { + looked.set(looked.get() + 1); + n.is_ruler_guide() + }; + let mut taken = vec![]; + take_sublayer_guides(&mut m, &mut taken, &is_guide); + assert_eq!(looked.get(), members(&root) - 2, "each member is looked at once, none in the quiet sublayer's group"); + assert_eq!(state(&taken), expected, "a sublayer's own guides, then its sublayers', each once"); + assert!(Arc::ptr_eq(&taken[0], &first_guide), "a guide with nothing to change is not copied"); + assert!(!Arc::ptr_eq(&taken[1], &second_guide), "a guide made hidden is copied"); + assert!(second_guide.visible, "the guide as it was stays as it was"); + let ch = m.children().unwrap(); + assert!(Arc::ptr_eq(&ch[3], &free), "a member with no sublayer guide is not copied"); + assert!(Arc::ptr_eq(&ch[4], &quiet), "a sublayer with no guide is not copied"); + assert!(ch[5].is_ruler_guide(), "the layer's own guide stays where it is"); + let ids = |n: &Node| n.children().into_iter().flatten().map(|c| c.id).collect::>(); + assert_eq!(ids(&ch[1]), [NodeId(102), NodeId(301)], "the sublayer's art stays, in order"); + assert_eq!(ids(&root.children().unwrap()[1]), [NodeId(102), NodeId(301), NodeId(201)], "the source keeps its guides"); + + fn found(n: &Node, out: &mut Vec) { + for c in n.children().into_iter().flatten() { + if c.is_ruler_guide() { + out.push(c.id); + } + found(c, out); + } + } + for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] { + root.appearance.effects = vec![Effect { id: effect.into(), params, visible: true }]; + let art = evaluate_container(None, &root).unwrap(); + let ch = art.children().unwrap(); + let tail = &ch[ch.len() - expected.len() - 1..]; + assert_eq!(tail[0].id, NodeId(20), "{effect}: the layer's own guide comes first"); + assert_eq!(state(&tail[1..]), expected, "{effect}: then the sublayers' guides"); + let mut all = vec![]; + found(&art, &mut all); + assert_eq!(all.len(), expected.len() + 1, "{effect}: each guide once"); + } +} + /// A group's own Fill + Offset Path below its contents follows the outline of a clip group among /// its members, its clipping path, not the clipped art it hides outside that path (#7289). #[test] diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 4b23c3af8c..92057f1c7f 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -8,6 +8,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, N use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; +use crate::solids::wireframe::WireFace; use crate::solids::{ Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, }; @@ -663,10 +664,16 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul /// effect on the object, the surface in its top visible fill (else its stroke); with it on fills and strokes, one surface for each of /// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for /// each member, about one shared axis. The swatches and patterns named by a live-effect plug-in -/// after a fill's or stroke's Revolve are looked up in `doc`. -pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { - let faces = |path: &PathData, b: Rect, options: Revolve| -> Result, String> { - Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect()) +/// after a fill's or stroke's Revolve are looked up in `doc`. Each face carries the width of the +/// stroke the canvas draws around it ([`surface`]). +pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { + let faces = |path: &PathData, b: Rect, fx: &Effect, options: Revolve| -> Result, String> { + if fx.id == CLASSIC_REVOLVE { + let p = merged_params(&fx.id, &fx.params); + let facets = vectorcraft_three_d::revolve_facets(path, b, options)?; + return Ok(facets.iter().map(|f| WireFace { path: f.path(), stroke: crate::solids::classic::stroke_width(f, &p) }).collect()); + } + Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(|f| WireFace { path: f.path(), stroke: SEAM_WIDTH }).collect()) }; if is_container(n) { let index = n @@ -682,7 +689,7 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result, n: &Node) -> Result, paint: &Paint, opacity: f32, blend: Ble .into_iter() .map(|face| { // Adjacent facets, antialiased on their own, would leave hairline seams: a narrow - // stroke of the same paint closes them. No wider than the facet is thick, though: seen - // at a grazing angle a facet can be thinner than the stroke, and its stroke would then - // paint over its neighbours. - let seam = (face.thickness() / 2.0).clamp(0.05, 0.75); + // stroke of the same paint closes them ([`seam_width`]). + let seam = seam_width(&face); if let Some(g) = face.rgb_gradient && (paint.color().is_some() || matches!(paint, Paint::Gradient(_))) { @@ -608,6 +606,13 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble group } +/// The width of the stroke of a facet's own paint that closes the seams between `face` and its +/// neighbors. No wider than the facet is thick: seen at a grazing angle a facet can be thinner than +/// the stroke, and its stroke would then paint over its neighbors. +pub(crate) fn seam_width(face: &Facet) -> f64 { + (face.thickness() / 2.0).clamp(0.05, 0.75) +} + /// Is a surface of `facets` in `paint` one composite of all its facets when [`surface`] draws it /// whole: a solid color under colored lights with smooth shading? fn composites_facets(facets: &[Facet], paint: &Paint, colored: bool) -> bool { diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 23d8d0ffad..29c05406a2 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -192,6 +192,12 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option) -> Color { Color::rgb(r, g, b) } +/// The width of the stroke [`surface`] draws around `face` with parameters `p`: a wire edge on +/// Wireframe, else the seam between faces. +pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 { + if classic_shading(p).surface == ClassicSurface::Wireframe { WIRE } else { super::seam_width(face) } +} + /// One solid's faces in paint `paint`, as a group with the paint's opacity and blend mode, one child /// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by /// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by @@ -213,7 +219,7 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 } let (tone, spec) = shading.tone(face.normal, &lights); // A narrow stroke of the face's own paint closes the hairline seams between faces. - let seam = (face.thickness() / 2.0).clamp(0.05, 0.75); + let seam = super::seam_width(&face); let items = match paint.color() { // Unshaded (No Shading, no shading color, or a face in full light) with no // highlight: the paint itself, its color space and swatch kept. diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index e6e7d89ab5..1e19a21c15 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -4,13 +4,13 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId}; use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; use vectorcraft_three_d::Facet; use super::{ - Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, members, object_profile, region, - reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, + members, object_profile, region, reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds, }; use crate::{GeomContext, is_geometry}; @@ -54,7 +54,7 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result = after_geometry(doc, n, faces, revolve).into_iter().map(|f| f.path).collect(); let wire = edges(&faces)?; if wire.is_empty() { return Err(format!("{label} draws no faces for this object")); @@ -65,29 +65,46 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, n: &Node, faces: Vec, revolve: bool) -> Vec { +fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec, revolve: bool) -> Vec { let fx = &n.appearance.effects; let drawn = if revolve { fx.iter().position(|e| e.visible && crate::revolve::is_revolve(&e.id)) } else { solid(fx).map(|(i, _)| i) }; let Some(index) = drawn else { return faces }; reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) }) } +/// One face of a drawn 3D surface: its outline and the width of the stroke the canvas draws around +/// it (the seam between faces, or a wire edge), which a later Outline Stroke outlines. +pub(crate) struct WireFace { + pub(crate) path: PathData, + pub(crate) stroke: f64, +} + /// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), as /// they reshape the faces of the drawn surface ([`crate::reshape::reshape_leaves`]): each effect /// reshapes each face on its own, about the bounds all the faces have before it, and the faces keep -/// to one budget of anchors together. `ctx` gives the object's id, the seed of a random Transform, -/// and the document. -pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext) -> Vec { +/// to one budget of anchors together. Each face is filled and carries its round-joined stroke, as on +/// the canvas, so an Outline Stroke outlines that stroke. `ctx` gives the object's id, the seed of a +/// random Transform, and the document. +pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext) -> Vec { let after: Vec<&Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).collect(); if after.is_empty() { return faces; } let id = NodeId(ctx.seed); - let fill = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); - let mut art = Node::group(id, faces.into_iter().map(|f| Arc::new(Node::path(id, f, fill.clone()))).collect()); + let paint = Paint::solid(Color::BLACK); + let face = |f: WireFace| { + let items = + vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), f.stroke))]; + Arc::new(Node::path(id, f.path, Appearance { items, ..Default::default() })) + }; + let mut art = Node::group(id, faces.into_iter().map(face).collect()); crate::reshape::reshape_leaves(&mut art, &after, later, ctx.doc); let mut out = vec![]; - art.walk(&mut |c| out.extend(c.path_data().cloned())); + art.walk(&mut |c| { + if let Some(path) = c.path_data() { + out.push(WireFace { path: path.clone(), stroke: c.appearance.stroke().map_or(0.0, |s| s.width) }); + } + }); out } @@ -95,7 +112,7 @@ pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext /// on the object, its solids; with it on fills and strokes, the solid of each; on a group or layer, /// the solids of its members, about one shared axis. The swatches and patterns named by a live-effect /// plug-in after a fill's or stroke's 3D effect are looked up in `doc`. -fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { +fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { if is_container(n) { let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing")?; let mut shapes = members(n, label_of(&fx.id))?; @@ -126,17 +143,20 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, S /// Rotate (Classic) turn every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's /// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out, /// as it is left out of the drawn art; past the limits of the object's solids together, the result -/// is an error, as the canvas draws the source art. -fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { +/// is an error, as the canvas draws the source art. Each facet carries the width of the stroke the +/// canvas draws around it. +fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { let label = label_of(&fx.id); let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; let p = crate::merged_params(&fx.id, &fx.params); + let classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE; + let face = |f: &Facet| WireFace { path: f.path(), stroke: if classic { super::classic::stroke_width(f, &p) } else { super::seam_width(f) } }; let mut out = vec![]; if is_plane(&fx.id) { for s in shapes { for item in &s.items { let (area, rule) = region(&s.path, s.rule, item); - out.extend(facets(ROTATE, &area, rule, b, &p)?.iter().map(Facet::path)); + out.extend(facets(ROTATE, &area, rule, b, &p)?.iter().map(face)); } } return Ok(out); @@ -144,7 +164,7 @@ fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> let (parts, bounds) = solid_parts(shapes, b); // The solids the art draws, each past the limits of the object's solids together as it is. for f in built_solids(&fx.id, &p, &parts, bounds, vectorcraft_three_d::MAX_OBJECT_FACES) { - out.extend(f?.iter().flatten().map(Facet::path)); + out.extend(f?.iter().flatten().map(face)); } Ok(out) } diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 792fbdb28a..3d92d3d277 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -163,13 +163,43 @@ fn revolve_classic_and_rotate_classic_expand_too() { fn later_geometry_effects_reshape_each_face_on_its_own() { let n = solid_square(super::super::EXTRUDE, json!({})); let fx = [crate::new_effect("path.offsetPath", &json!({"offset": 1.0})).unwrap()]; - let out = reshaped(vec![square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], &fx, &GeomContext::of(&n)); - assert_eq!(out.len(), 2, "{out:?}"); - let (a, b) = (out[0].bounds().unwrap(), out[1].bounds().unwrap()); + let faces = [square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)].map(|path| WireFace { path, stroke: 0.75 }); + let out = reshaped(faces.into(), &fx, &GeomContext::of(&n)); + assert_eq!(out.len(), 2); + let (a, b) = (out[0].path.bounds().unwrap(), out[1].path.bounds().unwrap()); assert!((a.x0 + 1.0).abs() < 1e-3 && (a.x1 - 11.0).abs() < 1e-3, "{a:?}"); assert!((b.x0 - 9.0).abs() < 1e-3 && (b.x1 - 21.0).abs() < 1e-3, "{b:?}"); } +/// An Outline Stroke after a 3D effect outlines the stroke each face is drawn with (the seam between +/// faces, or a Classic wire edge), not a plain 1 pt stroke: the wireframe covers the outlined faces +/// the canvas draws, in the same place. +#[test] +fn a_later_outline_stroke_outlines_the_stroke_each_face_is_drawn_with() { + let outline = || crate::new_effect("path.outlineStroke", &json!({})).unwrap(); + let cases = [ + (super::super::EXTRUDE, json!({})), + (super::super::CLASSIC_EXTRUDE, json!({})), + (super::super::CLASSIC_EXTRUDE, json!({"surface": "wireframe"})), + (super::super::INFLATE, json!({})), + (ROTATE, json!({})), + (crate::REVOLVE, json!({})), + (crate::CLASSIC_REVOLVE, json!({})), + (crate::CLASSIC_REVOLVE, json!({"surface": "wireframe"})), + ]; + for (id, params) in cases { + let mut n = solid_square(id, params.clone()); + n.appearance.effects.push(outline()); + let wire = three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id} {params}: {e}")); + let art = if crate::revolve::is_revolve(id) { crate::revolve_art(&n) } else { super::super::solid_art(&n) }.unwrap(); + let drawn = crate::group::shape_art(None, &art, None).unwrap(); + let (w, a) = (bounds(&wire), bounds(&face_paths(&drawn))); + for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{id} {params}: {w:?} {a:?}"); + } + } +} + /// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface. #[test] fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index 6e7b4a3f07..025be8b877 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -21,6 +21,7 @@ use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_render::effects; use super::super::appearance::series_children_in_paint_order; +use super::super::colorcmds::edit_if_changed; use super::super::edit::selected_roots; use super::super::graph::{capture_series_object_opacity, series_groups_among}; use super::super::menucmds::whole_or; @@ -92,7 +93,7 @@ pub fn specs() -> Vec { "Randomize Opacity", ["Object", "Plug-ins", "Arrange Tools"], None, - "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw, and on a series group, the group's own (docs/mcp.md) → {count}", + "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step (none when no opacity changes); it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw, and on a series group, the group's own (docs/mcp.md) → {count}", has_selection, randomize_opacity ), @@ -397,7 +398,8 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "count": 0 })); } let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32); - s.edit("Randomize Opacity", |d, _| { + // A run that gives every object the opacity it has records no undo step. + edit_if_changed(s, "Randomize Opacity", |_, d| { let mut changed = vec![]; for id in &ids { let o = rng.range(lo, hi); @@ -409,7 +411,7 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result { } } keep_series_opacity(d, &changed); - Ok(()) + Ok(((), !changed.is_empty())) })?; Ok(json!({ "count": ids.len() })) } diff --git a/crates/engine/src/cmd/addons/arrange/tests.rs b/crates/engine/src/cmd/addons/arrange/tests.rs index 85191935b2..759f6f321e 100644 --- a/crates/engine/src/cmd/addons/arrange/tests.rs +++ b/crates/engine/src/cmd/addons/arrange/tests.rs @@ -393,6 +393,25 @@ fn randomize_opacity_takes_ids_and_leaves_the_rest() { assert_eq!((opacity(&s, g), opacity(&s, a), opacity(&s, b)), (0.2, 1.0, 1.0)); } +#[test] +fn randomize_opacity_records_no_undo_step_when_no_opacity_changes() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 5.0, 5.0); + let b = rect(&mut s, 10.0, 0.0, 5.0, 5.0); + select(&mut s, &[a, b]); + let n = steps(&s); + // Every object already has the one opacity the range allows. + let r = s.execute("addon.arrange.randomizeOpacity", &json!({"min": 100, "max": 100})).unwrap(); + assert_eq!(r["count"], json!(2)); + assert_eq!(steps(&s), n); + assert_eq!((opacity(&s, a), opacity(&s, b)), (1.0, 1.0)); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 40, "max": 40})).unwrap(); + assert_eq!(steps(&s), n + 1); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 40, "max": 40})).unwrap(); + assert_eq!(steps(&s), n + 1, "the same opacities again"); + assert_eq!((opacity(&s, a), opacity(&s, b)), (0.4, 0.4)); +} + #[test] fn randomize_opacity_refuses_bad_params() { let mut s = session(); diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 052ba901ed..00d75e9888 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Break Text", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{by?: \"lines\"|\"paragraphs\"|\"words\"|\"glyphs\" (lines), ids?: [id…] (default: the selection)} each horizontal point or area type object becomes point type objects, one per line, paragraph, word or glyph, each where its characters were and with their character styles, as one undo step; spaces between pieces go, a paragraph's lines stay lines (a wrapped line ends in a line break), justified lines close up, and a paragraph broken out whole keeps its bullet (not its number; other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are → {ids (the pieces, selected), skipped}", + "{by?: \"lines\"|\"paragraphs\"|\"words\"|\"glyphs\" (lines), ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} each horizontal point or area type object becomes point type objects, one per line, paragraph, word or glyph, each where its characters were and with their character styles, as one undo step; spaces between pieces go, a paragraph's lines stay lines (a wrapped line ends in a line break), justified lines close up, and a paragraph broken out whole keeps its bullet (not its number; other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are → {ids (the pieces, selected), skipped}", has_selection, break_text ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Combine Point Text", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{ids?: [id…] (default: the selection)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}", + "{ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike, as visible and as locked (their groups and layers included), whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}", has_selection, combine_text ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Join Point Text", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{ids?: [id…] (default: the selection)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}", + "{ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one, and as visible and as locked (their groups and layers included), are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}", has_selection, join_text ), @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Remove Breaks", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool (after Select All in a frame of threaded text, in the text of every frame of the story), else in all the text of the selected type, as one undo step; a break between two words becomes a space, the frames of a threaded story selected all together being read as one text → {ids, removed}", + "{kind: \"line\"|\"paragraph\", ids?: [id…] (the type they name is taken locked or hidden too)} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool (after Select All in a frame of threaded text, in the text of every frame of the story), else in all the text of the selected type, as one undo step; a break between two words becomes a space, the frames of a threaded story selected all together being read as one text → {ids, removed}", has_selection, remove_breaks ), @@ -90,7 +90,7 @@ pub fn specs() -> Vec { "Set Alignment, Keep Position", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{align: \"left\"|\"center\"|\"right\", ids?: [id…] (default: the selection)} align every paragraph of each horizontal point type object so, moving the object so its longest line stays where it is (one line: the text doesn't move), as one undo step → {ids (the objects changed)}", + "{align: \"left\"|\"center\"|\"right\", ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} align every paragraph of each horizontal point type object so, moving the object so its longest line stays where it is (one line: the text doesn't move), as one undo step → {ids (the objects changed)}", has_selection, align_in_place ), @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Reset Text Transform", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{scale?: bool (false: the horizontal scale stays; true: it goes too, the object's and every character's), ids?: [id…] (default: the selection)} take the rotation, shear and reflection off each point type object, its size and the centre of its text staying, as one undo step → {ids (the objects changed)}", + "{scale?: bool (false: the horizontal scale stays; true: it goes too, the object's and every character's), ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} take the rotation, shear and reflection off each point type object, its size and the centre of its text staying, as one undo step → {ids (the objects changed)}", has_selection, reset_transform ), @@ -123,8 +123,17 @@ fn editing_text(s: &Session) -> std::result::Result<(), String> { } } -/// The type objects under `roots` (inside groups and layers, not live art) that can be edited. -fn texts_under(d: &Document, roots: &[NodeId]) -> Result> { +/// The type objects a command acts on: those under the objects `ids` names, locked and hidden ones +/// included, else those under the selection that can be edited ([`texts_under`]). +fn target_texts(s: &Session, p: &Value, command: &str) -> Result> { + let named = p.get("ids").is_some_and(|v| !v.is_null()); + texts_under(&s.doc()?.doc, &checked_ids_or_selection(s, p, command)?, named) +} + +/// The type objects under `roots` (inside groups and layers, not live art). When `named`, every one +/// not on a locked artboard, locked and hidden ones (and ones in a locked or hidden group or layer) +/// included; else only those that can be edited. +fn texts_under(d: &Document, roots: &[NodeId], named: bool) -> Result> { fn visit(n: &Node, out: &mut Vec) { match &n.kind { NodeKind::Text(_) => out.push(n.id), @@ -134,8 +143,13 @@ fn texts_under(d: &Document, roots: &[NodeId]) -> Result> { } let mut out = vec![]; roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out)); - let editable = d.editable(); - out.retain(|id| editable(*id)); + if named { + let locks = d.locked_artboards(); + out.retain(|id| !locks.holds_id(*id)); + } else { + let editable = d.editable(); + out.retain(|id| editable(*id)); + } out.dedup(); if out.len() > MAX_TEXTS { return Err(EngineError::Other("Too many type objects to change at once".into())); @@ -376,7 +390,9 @@ fn replace_with_pieces(s: &mut Session, label: &str, items: Vec<(NodeId, Node, V places.push((id, nodes)); } d.insert_beside(places, true)?; - sel.set(made.iter().copied()); + // Pieces of locked or hidden type stay unselected. + let editable = d.editable(); + sel.set(made.iter().copied().filter(|id| editable(*id))); Ok(made.iter().map(|i| i.0).collect()) }) } @@ -396,7 +412,7 @@ fn break_text(s: &mut Session, p: &Value) -> Result { }; let d = &s.doc()?.doc; let (mut items, mut skipped, mut count) = (vec![], vec![], 0); - let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?; + let ids = target_texts(s, p, C)?; let threads = thread_index(d); for id in ids { let (Some(src), Some(node)) = (Source::of(d, id, threads.contains_key(&id)), d.node(id)) else { @@ -450,6 +466,9 @@ fn split_text(s: &mut Session, _: &Value) -> Result { struct Piece { id: NodeId, t: TextObject, + /// Whether the object and every group and layer above it are visible, and whether they are all + /// unlocked. + state: (bool, bool), /// The object's turn and shear (`xf` without its move). turn: [f64; 4], /// Where its first character's glyph starts on its baseline, in that frame. @@ -487,13 +506,19 @@ impl Piece { if !(at.x.is_finite() && at.y.is_finite() && size > 0.0) { return None; } - Some((Self { id, t, turn: [a, b, c, dd], at, end: at.x + end, size, pens }, node)) + let state = d.ancestry(id)?.iter().filter_map(|i| d.node(*i)).fold((true, true), |(v, u), n| (v && n.visible, u && !n.locked)); + Some((Self { id, t, state, turn: [a, b, c, dd], at, end: at.x + end, size, pens }, node)) } /// Whether `o` is turned and sheared alike. fn turned_like(&self, o: &Piece) -> bool { self.turn.iter().zip(&o.turn).all(|(x, y)| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0)) } + + /// Whether `o` is turned and sheared alike and has the same `state`. + fn joins(&self, o: &Piece) -> bool { + self.turned_like(o) && self.state == o.state + } } /// Whether two objects look alike apart from their text (paint, opacity, blending, effects). @@ -595,15 +620,14 @@ fn merge(chain: &[&Piece]) -> std::result::Result { fn combine_text(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.cleanup.combineText"; let d = &s.doc()?.doc; - let mut pieces: Vec<(Piece, &Node)> = - texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id)).collect(); - // Lines: pieces turned alike and looking alike, by baseline, then left to right. + let mut pieces: Vec<(Piece, &Node)> = target_texts(s, p, C)?.into_iter().filter_map(|id| Piece::of(d, id)).collect(); + // Lines: pieces turned alike, as visible and as locked and looking alike, by baseline, then left + // to right. pieces.sort_by(|x, y| x.0.at.y.total_cmp(&y.0.at.y).then(x.0.at.x.total_cmp(&y.0.at.x))); let mut lines: Vec> = vec![]; for (piece, node) in &pieces { let fits = |l: &Vec<(&Piece, &Node)>| { - l.first() - .is_some_and(|(f, n)| f.turned_like(piece) && same_look(n, node) && (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size)) + l.first().is_some_and(|(f, n)| f.joins(piece) && same_look(n, node) && (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size)) }; match lines.iter_mut().find(|l| fits(l)) { Some(l) => l.push((piece, node)), @@ -672,7 +696,8 @@ fn combine_text(s: &mut Session, p: &Value) -> Result { gone.extend(others); } d.remove_many(&gone)?; - sel.set(ids.iter().map(|i| NodeId(*i))); + let editable = d.editable(); + sel.set(ids.iter().map(|i| NodeId(*i)).filter(|id| editable(*id))); Ok(()) })?; Ok(json!({ "ids": ids, "combined": combined })) @@ -681,15 +706,15 @@ fn combine_text(s: &mut Session, p: &Value) -> Result { fn join_text(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.cleanup.joinText"; let d = &s.doc()?.doc; - let pieces: Vec = - texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id).map(|p| p.0)).collect(); - // The piece top-left on the page sets the turn; the others turned like it are read in that frame. + let pieces: Vec = target_texts(s, p, C)?.into_iter().filter_map(|id| Piece::of(d, id).map(|p| p.0)).collect(); + // The piece top-left on the page sets the turn; the others turned like it, and as visible and as + // locked, are read in that frame. let on_page = |p: &Piece| Affine::new([p.turn[0], p.turn[1], p.turn[2], p.turn[3], 0.0, 0.0]) * p.at; let first = pieces.iter().min_by(|x, y| { let (a, b) = (on_page(x), on_page(y)); a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x)) }); - let mut kept: Vec<&Piece> = first.map(|f| pieces.iter().filter(|p| p.turned_like(f)).collect()).unwrap_or_default(); + let mut kept: Vec<&Piece> = first.map(|f| pieces.iter().filter(|p| f.joins(p)).collect()).unwrap_or_default(); kept.sort_by(|x, y| x.at.y.total_cmp(&y.at.y).then(x.at.x.total_cmp(&y.at.x))); // Lines, top to bottom, by baseline; each line's pieces left to right. let mut lines: Vec> = vec![]; @@ -751,7 +776,7 @@ fn join_text(s: &mut Session, p: &Value) -> Result { n.kind = NodeKind::Text(Box::new(m)); } d.remove_many(&gone)?; - sel.set([id]); + sel.set([id].into_iter().filter(|id| d.is_editable(*id))); Ok(()) })?; Ok(json!({ "id": id.0, "joined": joined, "lines": count })) @@ -797,8 +822,8 @@ fn apply_cuts(t: &mut TextObject, cuts: &[(usize, bool)], brk: char) -> usize { /// The breaks `brk` in the text of the frames of `story`, read as one text: a break at the end or /// start of a frame between words of the frames on either side becomes a space. Each editable frame -/// comes with its own breaks, at its own bytes. -fn story_cuts(d: &Document, story: &[NodeId], brk: char) -> Vec<(NodeId, Vec<(usize, bool)>)> { +/// (each frame when `all`) comes with its own breaks, at its own bytes. +fn story_cuts(d: &Document, story: &[NodeId], brk: char, all: bool) -> Vec<(NodeId, Vec<(usize, bool)>)> { let texts: Vec<(NodeId, String)> = story.iter().filter_map(|id| Some((*id, text_of(d, *id)?.plain_text()))).collect(); let joined: String = texts.iter().map(|(_, t)| t.as_str()).collect(); // The breaks in story order, handed to the frames in one pass. @@ -811,7 +836,7 @@ fn story_cuts(d: &Document, story: &[NodeId], brk: char) -> Vec<(NodeId, Vec<(us while let Some((at, space)) = cuts.next_if(|(at, _)| *at < end) { own.push((at - start, space)); } - if d.is_editable(id) { + if all || d.is_editable(id) { out.push((id, own)); } start = end; @@ -834,14 +859,14 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; // Each type object with the breaks that go from it. let targets: Vec<(NodeId, Vec<(usize, bool)>)> = match editing { - Some(_) if !story.is_empty() => story_cuts(d, &story, brk), + Some(_) if !story.is_empty() => story_cuts(d, &story, brk, false), Some((id, a, b)) => vec![(id, a.min(b), a.max(b))] .into_iter() .filter(|(id, ..)| d.is_editable(*id)) .filter_map(|(id, a, b)| Some((id, break_cuts(&text_of(d, id)?.plain_text(), a, b, brk)))) .collect(), None => { - let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?; + let ids = target_texts(s, p, C)?; // A threaded story whose frames are all among them is read as one text, once. let named: std::collections::HashSet = ids.iter().copied().collect(); let threads = thread_index(d); @@ -857,7 +882,8 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { }; if !story.is_empty() && story.iter().all(|f| named.contains(f)) { done.extend(story.iter().copied()); - out.extend(story_cuts(d, &story, brk)); + // Every frame is among them, so each one keeps its breaks. + out.extend(story_cuts(d, &story, brk, true)); } else if let Some(t) = text_of(d, id) { out.push((id, break_cuts(&t.plain_text(), 0, usize::MAX, brk))); } @@ -891,10 +917,8 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { /// Horizontal point type among the objects `p` names. fn point_texts(s: &Session, p: &Value, cmd: &str) -> Result> { let d = &s.doc()?.doc; - let ids: Vec = texts_under(d, &checked_ids_or_selection(s, p, cmd)?)? - .into_iter() - .filter(|id| text_of(d, *id).is_some_and(|t| matches!(t.kind, TextKind::Point))) - .collect(); + let ids: Vec = + target_texts(s, p, cmd)?.into_iter().filter(|id| text_of(d, *id).is_some_and(|t| matches!(t.kind, TextKind::Point))).collect(); if ids.is_empty() { return Err(EngineError::Other("Select point type".into())); } diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index cfa78a5ce5..c9ed79122c 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -379,6 +379,196 @@ fn remove_breaks_reads_the_threads_once() { } } +/// Lock `id` (`locked`), else hide it, as an undo step of its own. +fn lock_or_hide(s: &mut Session, id: NodeId, locked: bool) { + s.edit("Lock or Hide", |d, _| { + let n = d.node_mut(id).unwrap(); + if locked { + n.locked = true; + } else { + n.visible = false; + } + Ok(()) + }) + .unwrap(); +} + +/// Remove Breaks takes the locked and hidden type that `ids` names, and the locked member of a +/// group it names, as one undo step; from the selection it takes only type that can be edited, and +/// nothing changed makes no undo step. +#[test] +fn remove_breaks_named_locked_and_hidden_type() { + let mut s = session(); + let locked = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb", "size": 12})); + let hidden = text(&mut s, json!({"x": 40, "y": 120, "text": "c\nd", "size": 12})); + let grouped = text(&mut s, json!({"x": 40, "y": 180, "text": "e\nf", "size": 12})); + let free = text(&mut s, json!({"x": 40, "y": 240, "text": "g\nh", "size": 12})); + select(&mut s, &[grouped, free]); + let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + lock_or_hide(&mut s, locked, true); + lock_or_hide(&mut s, hidden, false); + lock_or_hide(&mut s, grouped, true); + select(&mut s, &[locked, hidden]); + let steps = undo_steps(&s); + assert_eq!(other(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"}))), "No paragraph breaks to remove"); + assert_eq!(undo_steps(&s), steps, "the selected locked and hidden type stays as it is"); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": [locked.0, hidden.0, group.0]})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(r["removed"], 4); + for (id, now) in [(locked, "a b"), (hidden, "c d"), (grouped, "e f"), (free, "g h")] { + assert_eq!(obj(&s, id).plain_text(), now); + } + assert_eq!(other(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": [locked.0]}))), "No paragraph breaks to remove"); + assert_eq!(undo_steps(&s), steps + 1, "nothing changed, no undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, locked).plain_text(), "a\nb"); +} + +/// A threaded story whose frames `ids` all names is read as one text even when a frame is locked. +#[test] +fn remove_breaks_named_story_with_a_locked_frame() { + let mut s = session(); + let (a, b) = threaded(&mut s, "one\ntwo"); + lock_or_hide(&mut s, a, true); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": [a.0, b.0]})).unwrap(); + assert_eq!(r["removed"], 1); + assert_eq!(obj(&s, a).plain_text(), "one two"); +} + +/// Type on a locked artboard stays as it is even when `ids` names it, with no undo step. +#[test] +fn remove_breaks_named_type_on_a_locked_artboard() { + let mut s = session(); + let on_board = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb", "size": 12})); + s.edit("Lock Artboard", |d, _| { + d.artboards[0].locked = true; + Ok(()) + }) + .unwrap(); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": [on_board.0]})); + assert_eq!(other(r), "No paragraph breaks to remove"); + assert_eq!(undo_steps(&s), steps); + assert_eq!(obj(&s, on_board).plain_text(), "a\nb"); +} + +/// Break Text, Combine and Join Point Text, Set Alignment, Keep Position and Reset Text Transform +/// act on the locked and hidden type that `ids` names (type in a locked layer included), each as +/// one undo step, and leave it as it is when it is only selected. +#[test] +fn named_locked_and_hidden_type() { + let mut s = session(); + let words = text(&mut s, json!({"x": 40, "y": 100, "text": "Two words", "size": 14})); + lock_or_hide(&mut s, words, true); + select(&mut s, &[words]); + assert!(s.execute("addon.cleanup.breakText", &json!({"by": "words"})).is_err()); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "words", "ids": [words.0]})).unwrap(); + assert_eq!((ids(&r).len(), undo_steps(&s)), (2, steps + 1)); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the locked pieces stay unselected"); + + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20})); + let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20})); + lock_or_hide(&mut s, a, false); + lock_or_hide(&mut s, b, false); + select(&mut s, &[a, b]); + assert!(s.execute("addon.cleanup.combineText", &json!({})).is_err()); + let r = s.execute("addon.cleanup.combineText", &json!({"ids": [a.0, b.0]})).unwrap(); + assert_eq!(ids(&r), [a]); + assert_eq!(obj(&s, a).plain_text(), "Left Right"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the hidden object stays unselected"); + + let mut s = session(); + let first = text(&mut s, json!({"x": 50, "y": 100, "text": "First", "size": 20})); + let second = text(&mut s, json!({"x": 50, "y": 130, "text": "Second", "size": 20})); + let layer = s.doc().unwrap().doc.layers[0].id; + lock_or_hide(&mut s, layer, true); + select(&mut s, &[first, second]); + assert!(s.execute("addon.cleanup.joinText", &json!({})).is_err()); + let r = s.execute("addon.cleanup.joinText", &json!({"ids": [first.0, second.0]})).unwrap(); + assert_eq!(r["id"].as_u64(), Some(first.0)); + assert_eq!(obj(&s, first).plain_text(), "First\nSecond"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the object in the locked layer stays unselected"); + + let mut s = session(); + let lines = text(&mut s, json!({"x": 50, "y": 100, "text": "a\nlonger line", "size": 20})); + lock_or_hide(&mut s, lines, false); + select(&mut s, &[lines]); + assert!(s.execute("addon.cleanup.alignInPlace", &json!({"align": "center"})).is_err()); + s.execute("addon.cleanup.alignInPlace", &json!({"align": "center", "ids": [lines.0]})).unwrap(); + assert!(obj(&s, lines).para_styles().all(|p| p.justify == Justify::Center)); + + let mut s = session(); + let turned = text(&mut s, json!({"x": 300, "y": 200, "text": "Turned", "size": 18, "rotation": 30})); + lock_or_hide(&mut s, turned, true); + select(&mut s, &[turned]); + assert!(s.execute("addon.cleanup.resetTextTransform", &json!({})).is_err()); + let steps = undo_steps(&s); + s.execute("addon.cleanup.resetTextTransform", &json!({"ids": [turned.0]})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let [_, b, c, _, _, _] = obj(&s, turned).xf.as_coeffs(); + assert!(b.abs() < 1e-9 && c.abs() < 1e-9, "the rotation is gone"); +} + +/// Combine Point Text joins named pieces only with pieces as visible and as locked, a group's lock +/// included: a visible piece and a hidden one beside it, or a free piece and one in a locked group, +/// stay as they are, with no undo step. +#[test] +fn combine_keeps_hidden_and_locked_pieces_apart() { + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20})); + let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20})); + lock_or_hide(&mut s, b, false); + let steps = undo_steps(&s); + assert!(s.execute("addon.cleanup.combineText", &json!({"ids": [a.0, b.0]})).is_err()); + assert_eq!(undo_steps(&s), steps); + assert_eq!((obj(&s, a).plain_text(), obj(&s, b).plain_text()), ("Left".into(), "Right".into())); + let d = &s.doc().unwrap().doc; + assert!(d.is_visible(a) && !d.is_visible(b), "the visible text stays shown and the hidden text stays hidden"); + + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20})); + let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20})); + select(&mut s, &[b]); + let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + lock_or_hide(&mut s, group, true); + let steps = undo_steps(&s); + assert!(s.execute("addon.cleanup.combineText", &json!({"ids": [a.0, group.0]})).is_err()); + assert_eq!(undo_steps(&s), steps); + assert_eq!((obj(&s, a).plain_text(), obj(&s, b).plain_text()), ("Left".into(), "Right".into())); +} + +/// Join Point Text joins only the named pieces as visible and as locked as the top-left one: a +/// hidden piece between two visible lines stays as it is, hidden, and a hidden top-left piece +/// with only a visible one below it joins nothing, with no undo step. +#[test] +fn join_keeps_a_hidden_piece_apart() { + let mut s = session(); + let first = text(&mut s, json!({"x": 50, "y": 100, "text": "First", "size": 20})); + let hidden = text(&mut s, json!({"x": 50, "y": 130, "text": "Hidden", "size": 20})); + let third = text(&mut s, json!({"x": 50, "y": 160, "text": "Third", "size": 20})); + lock_or_hide(&mut s, hidden, false); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.joinText", &json!({"ids": [first.0, hidden.0, third.0]})).unwrap(); + assert_eq!((r["id"].as_u64(), r["joined"].as_u64(), undo_steps(&s)), (Some(first.0), Some(2), steps + 1)); + assert_eq!(obj(&s, first).plain_text(), "First\nThird"); + assert_eq!(obj(&s, hidden).plain_text(), "Hidden"); + let d = &s.doc().unwrap().doc; + assert!(d.is_visible(first) && !d.is_visible(hidden), "the visible text stays shown and the hidden text stays hidden"); + assert!(d.node(third).is_none()); + + let mut s = session(); + let top = text(&mut s, json!({"x": 50, "y": 100, "text": "Top", "size": 20})); + let below = text(&mut s, json!({"x": 50, "y": 130, "text": "Below", "size": 20})); + lock_or_hide(&mut s, top, false); + let steps = undo_steps(&s); + assert!(s.execute("addon.cleanup.joinText", &json!({"ids": [top.0, below.0]})).is_err()); + assert_eq!(undo_steps(&s), steps); + assert_eq!((obj(&s, top).plain_text(), obj(&s, below).plain_text()), ("Top".into(), "Below".into())); + assert!(s.doc().unwrap().doc.is_visible(below)); +} + #[test] fn split_at_the_cursor_and_around_a_selection() { for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { diff --git a/crates/engine/src/cmd/addons/image/mask.rs b/crates/engine/src/cmd/addons/image/mask.rs index 786effbd2e..0dd8fabe3c 100644 --- a/crates/engine/src/cmd/addons/image/mask.rs +++ b/crates/engine/src/cmd/addons/image/mask.rs @@ -320,7 +320,8 @@ fn fresh(doc: &Document, n: &Node, cmd: &str) -> Option, doc: &mut Document) { diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 32b0f9fcd0..1046cada1c 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -180,7 +180,7 @@ pub fn specs() -> Vec { "Paintbrush", [], None, - "{…params as path.freehand, brush} paint a freehand stroke with a brush → {id}", + "{…params as path.freehand, brush?: name} paint a freehand stroke with a brush; without `brush` (or with null) the plain path is drawn; a `brush` that names no brush in the document, or a `style` other than \"pencil\" or \"brush\", is an error and nothing is drawn → {id}", has_doc, brush_freehand ), diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index 1f63076cfd..7fd8c9aa9a 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -729,8 +729,21 @@ pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&out) })) } +/// Paints a `path.freehand` stroke with the brush `brush` names. A `brush` left out or null draws +/// the plain path; a `brush` that is not a string or names no brush in the document, and a `style` +/// other than "pencil" or "brush", is an error before anything is drawn. pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "brush").map(str::to_string).filter(|n| s.doc().is_ok_and(|st| brush::find(&st.doc, n).is_some())); + const C: &str = "brush.freehand"; + let name = match crate::cmd::text_param(p, "brush", C)? { + None => None, + Some(n) if brush::find(&s.doc()?.doc, n).is_some() => Some(n.to_string()), + Some(n) => return Err(bad(C, format!("no brush named `{n}`"))), + }; + match p.get("style") { + None | Some(Value::Null) => {} + Some(v) if matches!(v.as_str(), Some("pencil" | "brush")) => {} + Some(v) => return Err(bad(C, format!("`style` must be \"pencil\" or \"brush\", not {v}"))), + } let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?; let r = crate::cmd::run_checked(s, spec, p)?; let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) }; diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 11519b84be..932ef1819a 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -526,12 +526,15 @@ fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result, /// The settings traced with, as JSON. @@ -600,7 +603,7 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result { let bytes = Arc::clone(&blob.bytes); let library = trace_library(c, s, &mut params)?; let traced_with = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; - let inputs = TraceInputs { target, image, params: traced_with, swatches: params.swatches.clone(), bytes }; + let inputs = TraceInputs { doc: s.doc()?.uid, target, image, params: traced_with, swatches: params.swatches.clone(), bytes }; Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library }) } @@ -612,8 +615,9 @@ pub struct Traced { } impl Traced { - /// Hand it to the next run of its command with the same inputs ([`TraceRun::params`]), which - /// applies it as one undo step instead of tracing again. Any other trace drops it. + /// Hand it to the next run of its command with the same inputs ([`TraceRun::params`]) in the + /// document it was made in, which applies it as one undo step instead of tracing again. Any + /// other trace drops it, a trace in another document included. pub fn hand_in(self, s: &mut Session) { s.trace_hand_off = Some(self); } @@ -686,7 +690,7 @@ impl TraceRun { } /// Does it still apply: would the command trace the same object, image, pixels and settings - /// now? + /// now, in the document it was prepared in? pub fn current(&self, s: &Session) -> bool { prepare(s, self.command, &self.params).is_ok_and(|now| now.inputs == self.prepared.inputs) } diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 66295f50f2..4e4da57f9c 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -9,7 +9,8 @@ //! ([`use_front_profiles`]): when they are assigned, when the working settings change, and when the //! document comes to the front or its assignment comes back (an open, a switch, a close, a revert, //! an undo or a rollback, [`Session::sync_front_profiles`]); the working spaces stay as Edit → Color -//! Settings set them. +//! Settings set them. A document drawn while another is in front draws through its own profiles +//! ([`doc_cms`]). use std::collections::BTreeMap; use std::sync::Arc; @@ -236,6 +237,9 @@ fn load_profile(s: &mut Session, p: &Value) -> Result { } else if proof_targets(&info.name) { // A new file of the CMYK profile View → Proof Setup targets: redraw. touch_all(s); + } else { + // Documents behind the front one draw through their own profiles: those assigned it redraw. + touch_assigned(s, &info.name); } Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered})) } @@ -266,9 +270,21 @@ fn remove_profile(s: &mut Session, p: &Value) -> Result { if proof_targets(&info.name) { touch_all(s); } + // Documents behind the front one draw through their own profiles: those assigned it redraw. + touch_assigned(s, &info.name); Ok(json!({"removed": info.name})) } +/// Redraw the open documents assigned the profile `name`. +fn touch_assigned(s: &mut Session, name: &str) { + for d in &mut s.docs { + let (rgb, cmyk) = doc_profiles(&d.doc); + if [rgb, cmyk].iter().any(|n| n.as_deref() == Some(name)) { + d.revision += 1; + } + } +} + /// Whether View → Proof Setup targets the CMYK profile `name`. fn proof_targets(name: &str) -> bool { matches!(proof::view().setup.target, ProofTarget::Cmyk(t) if t == name) @@ -285,6 +301,14 @@ pub fn doc_profiles(d: &vectorcraft_doc::Document) -> (Option, Option Arc { + let (rgb, cmyk) = doc_profiles(d); + cms::for_document(rgb.as_deref(), cmyk.as_deref()) +} + fn assign_profile(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.assignProfile"; let (mut rgb, mut cmyk) = doc_profiles(&s.doc()?.doc); diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 2dbb7a9a18..8f1b5b97e6 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -26,7 +26,7 @@ pub use perspective::perspective_click; use perspective::{ attach, edit_text, grid_set, grid_show, persp_draw, persp_move, persp_nudge, persp_transform, plane_match, plane_move, plane_set, release, }; -pub(crate) use perspective::{finish_edit_text, grid_of, perspective_rewrite, silent, store_grid, transform_again}; +pub(crate) use perspective::{finish_edit_text, grid_kept, grid_of, perspective_rewrite, silent, store_grid, transform_again}; pub fn specs() -> Vec { vec![ @@ -107,7 +107,7 @@ pub fn specs() -> Vec { "Perspective Grid Preset", [], None, - "{kind: 1|2|3 (its normal view) | name: a preset (see perspective.presets.list)} reset the grid to the preset fitted to the first artboard (and show it; attached objects stay attached)", + "{kind: 1|2|3 (its normal view) | name: a preset (see perspective.presets.list)} reset the grid to the preset fitted to the first artboard (and show it; attached objects stay attached). No undo step when the document already holds that grid; when only whether it shows or its active plane differs, those are set without one", has_doc, super::perspgrid::grid_preset ), diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index 62b72a8e0f..89091248df 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -18,7 +18,7 @@ pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) { /// Whether `d` holds `g` as [`store_grid`] writes it (with `attached` cleared): then storing `g` /// leaves `d` as it is. -fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool { +pub(crate) fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool { let mut g = g.clone(); g.attached.clear(); serde_json::to_value(&g).is_ok_and(|v| d.unknown.get(persp::DOC_KEY) == Some(&v)) diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index 560990cec1..9f119fca86 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -49,6 +49,84 @@ fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64, free: [bool; 2]) -> Vec (usize, f64) { + let mut best: Option<(f64, usize, f64)> = None; + for j in 0..orig.anchors.len() { + let hit = orig.segment(j).nearest(p, 1e-9); + if hit.distance_sq.is_finite() && hit.t.is_finite() && best.is_none_or(|(d, ..)| hit.distance_sq < d) { + best = Some((hit.distance_sq, j, hit.t)); + } + } + best.map_or((usize::MAX, f64::INFINITY), |(_, j, t)| (j, t)) +} + +/// The closed `sp` started at the point on it nearest `start`: at the anchor there, or at a new +/// anchor split into the segment there, which leaves the outline as it is. Of points equally near +/// `start`, the one earliest along `orig` (the path before smoothing) from its first anchor is used, +/// then the first in `sp`'s order. An open `sp`, or one with no segment or no finite nearest point, +/// is returned as it is. +fn start_nearest(mut sp: SubPath, start: Point, orig: &SubPath) -> SubPath { + let n = sp.anchors.len(); + if !sp.closed || n < 2 { + return sp; + } + // (distance, place along `orig` once a tie needs it, segment of `sp`, parameter) + type Best = (f64, Option<(usize, f64)>, usize, f64); + let mut best: Option = None; + for i in 0..n { + let c = sp.segment(i); + let hit = c.nearest(start, 1e-9); + if !hit.distance_sq.is_finite() || !hit.t.is_finite() { + continue; + } + let d = hit.distance_sq.sqrt(); + match &mut best { + None => best = Some((d, None, i, hit.t)), + Some((bd, bplace, bi, bt)) if (d - *bd).abs() <= START_TIE => { + let held = *bplace.get_or_insert_with(|| place_along(orig, sp.segment(*bi).eval(*bt))); + let place = place_along(orig, c.eval(hit.t)); + if place.0 < held.0 || (place.0 == held.0 && place.1 < held.1) { + best = Some((d, Some(place), i, hit.t)); + } + } + Some((bd, ..)) if d < *bd => best = Some((d, None, i, hit.t)), + Some(_) => {} + } + } + let Some((_, _, i, t)) = best else { return sp }; + let c = sp.segment(i); + let at = c.eval(t); + let first = if at.distance(c.p0) <= START_SNAP { + i + } else if at.distance(c.p3) <= START_SNAP { + (i + 1) % n + } else { + let mid = if sp.segment_is_line(i) { + Anchor::corner(at) + } else { + let (l, r) = (c.subsegment(0.0..t), c.subsegment(t..1.0)); + sp.anchors[i].h_out = l.p1; + sp.anchors[(i + 1) % n].h_in = r.p2; + Anchor::with_handles(at, l.p2, r.p1) + }; + sp.anchors.insert(i + 1, mid); + i + 1 + }; + sp.anchors.rotate_left(first); + sp +} + /// Pieces of curve that one Smooth drag measures against the drag while it searches the segments /// under it ([`reach_samples`]), shared equally by the curved segments that come within its reach, /// each of which gets at least one: enough to search 300,000 pt of curve within the default reach @@ -226,6 +304,9 @@ fn mark_reached(sp: &SubPath, pts: &[Point], radius: f64, share: usize) -> (SubP (out, marked) } +/// `sp` with the anchors in the drag's reach smoothed and refitted, `None` when none are in reach. +/// A closed path whose first anchor is in reach starts at the point of the refitted path nearest +/// that anchor's place (VectorCraft's own rule). fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f64, sigma: f64) -> Option { if sp.anchors.len() < 2 { return None; @@ -238,10 +319,11 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f return None; } if sp.closed { + let start = sp.anchors[0].p; let Some(k) = marked.iter().position(|m| !m) else { // Everything is in range: smooth the whole closed path, across its start too. let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2])); - return f.subpaths.into_iter().next(); + return f.subpaths.into_iter().next().map(|r| start_nearest(r, start, sp)); }; // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. let mut open = sp.clone(); @@ -256,7 +338,9 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f if let Some(r0) = res.first_mut() { r0.h_in = l.h_in; } - return Some(SubPath::new(res, true)); + // With k = 0 the first anchor is out of reach and still starts the result. + let res = SubPath::new(res, true); + return Some(if k == 0 { res } else { start_nearest(res, start, sp) }); } Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma, true), false)) } @@ -621,6 +705,89 @@ pub(super) fn join_scrub(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; + /// A closed ring of four smooth anchors round (100, 100) with radius 50. + fn ring() -> SubPath { + let k = 0.5523 * 50.0; + let a = |x: f64, y: f64, dx: f64, dy: f64| Anchor::with_handles(Point::new(x, y), Point::new(x - dx, y - dy), Point::new(x + dx, y + dy)); + SubPath::new(vec![a(150.0, 100.0, 0.0, k), a(100.0, 150.0, -k, 0.0), a(50.0, 100.0, 0.0, -k), a(100.0, 50.0, k, 0.0)], true) + } + + /// A start between two anchors of a curve splits that curve there into two that trace it, and the + /// new anchor comes first. + #[test] + fn start_nearest_splits_a_curve_where_it_passes_the_start() { + let sp = ring(); + let old = sp.segment(0); + let start = old.eval(0.3) + (old.eval(0.3) - Point::new(100.0, 100.0)) * 0.1; + let out = start_nearest(sp.clone(), start, &sp); + assert!(out.closed && out.anchors.len() == 5, "{out:?}"); + let t = old.nearest(start, 1e-9).t; + assert!(out.anchors[0].p.distance(old.eval(t)) < 1e-9, "{out:?}"); + let (head, rest) = (out.segment(4), out.segment(0)); + for k in 0..=8 { + let u = f64::from(k) / 8.0; + assert!(head.eval(u).distance(old.eval(t * u)) < 1e-9); + assert!(rest.eval(u).distance(old.eval(t + (1.0 - t) * u)) < 1e-9); + } + for i in 1..4 { + assert_eq!(out.segment(i), sp.segment(i), "segment {i} stays"); + } + } + + /// A start within [`START_SNAP`] of an anchor starts the ring at that anchor, with no anchor added. + #[test] + fn start_nearest_starts_at_an_anchor_near_the_start() { + let sp = ring(); + let out = start_nearest(sp.clone(), Point::new(50.0, 100.0 + START_SNAP / 2.0), &sp); + let mut want = sp.anchors.clone(); + want.rotate_left(2); + assert_eq!(out.anchors, want); + } + + /// A start on a straight side of a triangle adds a corner point there without handles, and the + /// sides stay straight. + #[test] + fn start_nearest_splits_a_line_with_a_corner_point() { + let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(50.0, 80.0)], true); + let out = start_nearest(sp.clone(), Point::new(40.0, -5.0), &sp); + let want: Vec = [(40.0, 0.0), (100.0, 0.0), (50.0, 80.0), (0.0, 0.0)].map(|(x, y)| Point::new(x, y)).to_vec(); + assert!(out.anchors.len() == 4 && out.anchors.iter().zip(&want).all(|(a, w)| a.p.distance(*w) < 1e-9), "{out:?}"); + assert!((0..4).all(|i| out.segment_is_line(i)), "{out:?}"); + } + + /// A ring whose last anchor lies on its first keeps its first anchor first, and gets no third + /// anchor at that place. + #[test] + fn start_nearest_keeps_the_first_of_two_anchors_on_one_place() { + let p = [(0.0, 0.0), (100.0, 0.0), (50.0, 80.0), (0.0, 0.0)].map(|(x, y)| Point::new(x, y)); + let sp = SubPath::polyline(&p, true); + assert_eq!(start_nearest(sp.clone(), p[0], &sp), sp); + } + + /// Of points equally near the start, the one earliest along the path before smoothing from its + /// first anchor comes first, whatever the smoothed path's order. The center of a square is equally + /// near the middle of each side. The square before smoothing starts at (-50, -50), so the middle + /// of its first side, (0, -50), comes first, though the smoothed copy starts at (50, 50). + #[test] + fn start_nearest_breaks_a_tie_by_the_order_before_smoothing() { + let p = [(-50.0, -50.0), (50.0, -50.0), (50.0, 50.0), (-50.0, 50.0)].map(|(x, y)| Point::new(x, y)); + let orig = SubPath::polyline(&p, true); + let mut sp = orig.clone(); + sp.anchors.rotate_left(2); + let out = start_nearest(sp, Point::new(0.0, 0.0), &orig); + assert!(out.closed && out.anchors.len() == 5, "{out:?}"); + assert!(out.anchors[0].p.distance(Point::new(0.0, -50.0)) < 1e-9, "{out:?}"); + assert!(out.anchors[1].p.distance(p[1]) < 1e-9, "{out:?}"); + } + + /// An open path, and a start that is not a number, leave the path as it is. + #[test] + fn start_nearest_leaves_an_open_path_or_a_start_without_a_place() { + let open = SubPath { closed: false, ..ring() }; + assert_eq!(start_nearest(open.clone(), Point::new(100.0, 50.0), &ring()), open); + assert_eq!(start_nearest(ring(), Point::new(f64::NAN, 0.0), &ring()), ring()); + } + /// Searching a curve 1,000,000 pt long under a drag of 1,000 points along it measures no more /// pieces than the budget it is given, and a search with none left gives one sample. #[test] diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 232d5ba1f4..4e99f4ee3e 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -12,6 +12,8 @@ use serde_json::json; use super::super::{Result, Session, bad}; #[cfg(not(target_arch = "wasm32"))] use super::super::{check_switches, find_command, switch_or, text_param}; +#[cfg(not(target_arch = "wasm32"))] +use crate::EngineError; #[cfg(target_arch = "wasm32")] pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result { @@ -58,6 +60,10 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { if id == "command.batch" || id == "action.batch" { return Err(bad(C, "batches cannot nest")); } + // `command.batch` refuses the batch when it reaches this step, which would fail every file or data set it plays. + if id == "prefs.import" { + return Err(bad(C, format!("commands[{i}]: `prefs.import` runs on its own, not as a batch step"))); + } if find_command(id).is_none() { return Err(bad( C, @@ -213,7 +219,7 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: // open, so its artboard files count ([`dataset_output_paths`]). No output takes the name of a // source file in the destination folder, other than that file's own output, or of a subfolder // of the destination folder that holds source files. - let steps = filter_steps(run.steps, override_open, false); + let (steps, places) = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; let in_dest: Vec> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect(); let mut picker = @@ -228,7 +234,7 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: let outputs = dataset_output_extensions(&run, doc, &ext); picker.pick(&base, |stem| output_names(run.dest_folder, stem, &outputs, doc), own.as_deref()) }; - match process_one_file(s, &run, path, &ext, pick) { + match process_one_file(s, &run, &places, path, &ext, pick) { Ok(row) => processed.push(row), Err(e) => { failed += 1; @@ -266,12 +272,12 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { } let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string(); let stems = dataset_stems(&names, &doc_stem, run.dataset_file_naming, run, &st.doc)?; - let steps = filter_steps(run.steps, true, false); + let (steps, places) = filter_steps(run.steps, true, false); let run = Run { steps: &steps, ..*run }; let mut processed = Vec::new(); let mut failed = 0usize; for (name, stem) in names.iter().zip(&stems) { - match process_one_dataset(s, &run, name, stem) { + match process_one_dataset(s, &run, &places, name, stem) { Ok(row) => processed.push(row), Err(e) => { failed += 1; @@ -347,11 +353,13 @@ impl StemPicker { } } -/// Drop Open (and optionally Close) steps when the batch supplies the file itself. +/// Drop Open (and optionally Close) steps when the batch supplies the file itself → the steps that +/// stay, and the index of each in `cmds`. #[cfg(not(target_arch = "wasm32"))] -fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec { +fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> (Vec, Vec) { cmds.iter() - .filter(|c| { + .enumerate() + .filter(|(_, c)| { let id = c.get("command").and_then(Value::as_str).unwrap_or(""); if drop_open && matches!(id, "document.open" | "file.open") { return false; @@ -361,14 +369,44 @@ fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec } true }) - .cloned() - .collect() + .map(|(i, c)| (c.clone(), i)) + .unzip() +} + +/// Plays `commands`, the steps of `run` with their paths rewritten, as one `command.batch`. When a +/// step fails, the error names it by its place in the caller's `commands`, from 1, counting the Open +/// steps the batch skipped (`places` gives the index there of each step of `run`): +/// ``action step 3 (`object.move`) failed: …``. Any other error, such as one about the whole batch, +/// stays as it is. +#[cfg(not(target_arch = "wasm32"))] +fn play_steps(s: &mut Session, run: &Run<'_>, places: &[usize], commands: Vec) -> Result { + s.execute("command.batch", &json!({"label": run.label, "commands": commands})).map_err(|e| { + let text = e.to_string(); + let Some((n, id, reason)) = batch_step_failure(&text) else { return e }; + let same = run.steps.get(n).and_then(|c| c.get("command")).and_then(Value::as_str) == Some(id); + match places.get(n) { + Some(place) if same => EngineError::Other(format!("action step {} (`{id}`) failed: {reason}", place + 1)), + _ => e, + } + }) +} + +/// The step number, command id and reason in the error `command.batch` gives when one of its steps +/// fails (``batch step 2 (`object.move`) failed: …``); none for any other error. +#[cfg(not(target_arch = "wasm32"))] +fn batch_step_failure(e: &str) -> Option<(usize, &str, &str)> { + let rest = e.strip_prefix("batch step ")?; + let digits = rest.find(|c: char| !c.is_ascii_digit())?; + let n = rest[..digits].parse().ok()?; + let (id, reason) = rest[digits..].strip_prefix(" (`")?.split_once("`) failed: ")?; + Some((n, id, reason)) } #[cfg(not(target_arch = "wasm32"))] fn process_one_file( s: &mut Session, run: &Run<'_>, + places: &[usize], path: &str, ext: &str, stem_for: impl FnOnce(&vectorcraft_doc::Document) -> String, @@ -376,17 +414,17 @@ fn process_one_file( s.execute("document.open", &json!({"path": path}))?; let stem = stem_for(&s.doc()?.doc); let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, ext); - let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?; + let batch = play_steps(s, run, places, rewritten)?; // Folder sources open a file per pass: close after Save and Close / Folder. finish_destination(s, run, &stem, ext, path, true)?; Ok(json!({"path": path, "result": batch})) } #[cfg(not(target_arch = "wasm32"))] -fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, stem: &str) -> Result { +fn process_one_dataset(s: &mut Session, run: &Run<'_>, places: &[usize], name: &str, stem: &str) -> Result { s.execute("dataset.select", &json!({"name": name}))?; let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, "vectorcraft"); - let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?; + let batch = play_steps(s, run, places, rewritten)?; // Data sets share one open document: write each row out but leave it open for the next. finish_destination(s, run, stem, "vectorcraft", "", false)?; Ok(json!({"dataset": name, "result": batch})) @@ -1454,7 +1492,8 @@ mod tests { assert_eq!((r["failed"].as_u64(), r["processed"].as_array().map(Vec::len)), (Some(rows), Some(rows as usize)), "{r}"); for row in r["processed"].as_array().unwrap() { let e = row["error"].as_str().unwrap_or_default(); - let want = format!("batch step 0 (`document.save`) failed: automation path rejected: outside the write root {root}: doc.vectorcraft"); + let want = + format!("action step 1 (`document.save`) failed: automation path rejected: outside the write root {root}: doc.vectorcraft"); assert_eq!(e, want, "{r}"); } assert!(!r.to_string().contains(&hidden), "{r}"); @@ -2224,4 +2263,144 @@ mod tests { .is_err() ); } + + /// The steps of the step-number tests: an Open the batch skips, a step that works, and one that + /// fails (an Align with nothing selected). + fn open_then_failing_align() -> Value { + json!([ + {"command": "document.open", "params": {"path": "/no/such/file.svg"}}, + {"command": "object.unlockAll", "params": {}}, + {"command": "object.align", "params": {}} + ]) + } + + /// The step a source's error names, after `action step ` and before ` failed: `. + fn named_step(r: &Value, row: usize) -> String { + let e = r["processed"][row]["error"].as_str().unwrap_or_default(); + assert!(!e.contains("batch step"), "{r}"); + e.strip_prefix("action step ").and_then(|e| e.split_once(" failed: ")).map(|(step, _)| step.to_string()).unwrap_or_else(|| e.to_string()) + } + + /// A failed step of a folder batch is named by its place in the `commands` sent, from 1, with the + /// Open step that Override Open skips counted; the reason follows unchanged. + #[test] + fn a_folder_batch_error_names_the_step_in_the_commands_sent() { + let src = tmp("step-number-src"); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src, "b.svg", "#0f0"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + // `command.batch` itself still numbers its steps from 0. + let reason = s + .execute( + "command.batch", + &json!({"commands": [{"command": "object.unlockAll", "params": {}}, {"command": "object.align", "params": {}}]}), + ) + .unwrap_err() + .to_string(); + let reason = reason.strip_prefix("batch step 1 (`object.align`) failed: ").unwrap_or_else(|| panic!("{reason}")).to_string(); + for (stop, rows) in [(true, 1), (false, 2)] { + let r = s + .execute("action.batch", &json!({"folder": src.to_string_lossy(), "stopOnError": stop, "commands": open_then_failing_align()})) + .unwrap(); + assert_eq!(r["failed"], rows, "{r}"); + for row in 0..rows { + assert_eq!(r["processed"][row]["error"], format!("action step 3 (`object.align`) failed: {reason}"), "{r}"); + } + } + } + + /// With Override Open off, a recorded Open step runs, and its failure is named as the first step. + #[test] + fn a_recorded_open_that_fails_is_the_first_step() { + let src = tmp("step-number-open-src"); + write_svg(&src, "a.svg", "#f00"); + let mut s = Session::new(); + let r = s + .execute("action.batch", &json!({"folder": src.to_string_lossy(), "overrideOpen": false, "commands": open_then_failing_align()})) + .unwrap(); + assert_eq!(named_step(&r, 0), "1 (`document.open`)", "{r}"); + } + + /// A data set batch, which always skips Open steps, names a failed step by its place in the + /// `commands` sent for every data set it fails on. + #[test] + fn a_data_set_batch_error_names_the_step_in_the_commands_sent() { + let mut s = with_data_sets(&["Alpha", "Beta"]); + for (stop, rows) in [(true, 1), (false, 2)] { + let r = s.execute("action.batch", &json!({"source": "datasets", "stopOnError": stop, "commands": open_then_failing_align()})).unwrap(); + assert_eq!(r["failed"], rows, "{r}"); + for row in 0..rows { + assert_eq!(named_step(&r, row), "3 (`object.align`)", "{r}"); + } + } + } + + /// A recorded export that Override Export points into the destination folder keeps its place in + /// the `commands` sent when it fails. + #[test] + fn a_redirected_export_that_fails_keeps_its_place() { + let src = tmp("step-number-export-src"); + write_svg(&src, "a.svg", "#f00"); + let dest = tmp("step-number-export-out"); + // A file where the export's folder goes, so the export cannot write its files. + std::fs::write(dest.join("a"), "taken").unwrap(); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": dest.to_string_lossy(), + "commands": [ + {"command": "document.open", "params": {"path": "/no/such/file.svg"}}, + {"command": "object.unlockAll", "params": {}}, + {"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}} + ] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 1, "{r}"); + assert_eq!(named_step(&r, 0), "3 (`document.exportForScreens`)", "{r}"); + } + + /// A source file that does not open, which the batch opens itself, gives the open's own error + /// with no step number. + #[test] + fn a_source_that_does_not_open_names_no_step() { + let src = tmp("step-number-bad-src"); + std::fs::write(src.join("a.svg"), "not an svg").unwrap(); + let mut s = Session::new(); + let r = s.execute("action.batch", &json!({"folder": src.to_string_lossy(), "commands": open_then_failing_align()})).unwrap(); + assert_eq!(r["failed"], 1, "{r}"); + let e = r["processed"][0]["error"].as_str().unwrap_or_default(); + assert!(!e.is_empty() && !e.contains("action step") && !e.contains("batch step"), "{r}"); + } + + /// A `prefs.import` step, which `command.batch` does not run, is refused before any file opens. + #[test] + fn a_prefs_import_step_is_refused_before_any_file_opens() { + let src = tmp("step-number-prefs-src"); + write_svg(&src, "a.svg", "#f00"); + let mut s = Session::new(); + let p = json!({"folder": src.to_string_lossy(), "commands": [{"command": "object.unlockAll"}, {"command": "prefs.import", "params": {}}]}); + let e = s.execute("action.batch", &p).unwrap_err().to_string(); + assert!(e.contains("commands[1]: `prefs.import` runs on its own, not as a batch step"), "{e}"); + assert!(s.active().is_none(), "no file was opened"); + } + + /// Only the error `command.batch` gives for a failed step is read as one. + #[test] + fn batch_step_failure_reads_only_a_step_error() { + assert_eq!(batch_step_failure("batch step 12 (`object.move`) failed: no selection"), Some((12, "object.move", "no selection"))); + for e in [ + "batch step (`object.move`) failed: no selection", + "batch step 99999999999999999999999 (`object.move`) failed: x", + "batch step 1 `object.move` failed: x", + "invalid parameters for `command.batch`: batches cannot nest", + "batch step 1", + ] { + assert_eq!(batch_step_failure(e), None, "{e}"); + } + } } diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index a225bfcad9..ca74243699 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. A switch (includeSubdirs, overrideOpen, overrideSave, overrideExport, stopOnError) that is not true or false, or a source, folder, destination, destinationFolder, datasetFileNaming or label that is not a string, is an error before any folder is made or file opened; null reads as left out. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest, and a prefs.import step is refused (commands[i], from 0) before any file is opened. A switch (includeSubdirs, overrideOpen, overrideSave, overrideExport, stopOnError) that is not true or false, or a source, folder, destination, destinationFolder, datasetFileNaming or label that is not a string, is an error before any folder is made or file opened; null reads as left out. A step that fails is named in the error by its place in commands, from 1, counting the Open steps the batch skips (action step 3 (`object.align`) failed: …); the batch's own opening of a file, choosing of a data set and destination save, export or close give their errors without a step number. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", always, action_batch::action_batch ), diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index c834e653e4..a891a81ff2 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index fafcc12215..c48b437b76 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -424,8 +424,8 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() { /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{xa}0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n" ) }; - // Styles of one paint (`/SimpleStyle`): a red 2 pt stroke and no fill, and a gradient fill, - // which isn't read, so it is left out rather than listed with no paint. + // Styles of one paint (`/SimpleStyle`): a red 2 pt stroke and no fill, and a fill with a gradient + // the file doesn't define, which is left out rather than listed with no paint. let one_paint = |name: &str, paint: &str| format!("/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n"); let styles = format!( "%AI9_BeginArtStyles\n{}{}{}{}{}%AI9_EndArtStyles\n\ @@ -465,6 +465,64 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() { assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3), ("Red outline", 4)], "{r}"); } +/// A `.ai` graphic style library's styles that paint with one of the file's gradients by name (a +/// fill style's, and the fill or stroke of a style of one paint) load with that gradient at the +/// style's angle, fitted to the bounds of each object the style is applied to; one naming a gradient +/// the file doesn't define is left out. +#[test] +fn graphic_styles_in_a_ai_library_that_paint_with_a_named_gradient_load_with_it() { + let gradient = + "%AI5_BeginGradient: (Sky)\n(Sky) 0 2 Bd\n[\n%_0 0 0 0 0 1 0 2 1 6 50 0 Bs\n%_0 0 0 0 1 1 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let band = "/KnownStyle :\n(Sky band) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n\ + 0 Bb\n2 (Sky) 0 0 45 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n"; + let one_paint = |name: &str, paint: &str| format!("/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n"); + let styles = format!( + "%AI9_BeginArtStyles\n{band}{}{}{}%AI9_EndArtStyles\n\ + %AI9_BeginArtStyleList\n(Sky band)\n(Sky fill)\n(Sky outline)\n(Dusk fill)\n%AI9_EndArtStyleList\n", + one_paint("Sky fill", "0 O\n0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n"), + one_paint("Sky outline", "0 R\n0 J 0 j 2 w 10 M []0 d\n1 Bb\n2 (Sky) 0 0 90 100 1 0 0 1 0 0 1 Bg\n0 BB\n1 0 Xd\n"), + one_paint("Dusk fill", "0 O\n0 Bb\n2 (Dusk) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n") + ); + // A palette with the gradient's swatch, so the document opened from the file shows its gradient. + let palette = INKS_PALETTE.replace("0 0 Pb\n", "0 0 Pb\nBb\n2 (Sky) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n(Sky)\nPc\n"); + let data = ai::editing_data(200.0, 100.0, &format!("{gradient}{palette}{styles}")); + let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"); + let mut s = Session::new(); + let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "skies.ai"}); + let r = s.execute("graphicStyle.loadLibrary", &p).unwrap(); + let library = r["library"].clone(); + let lib = s.execute("graphicStyle.library", &json!({"library": library})).unwrap(); + let names: Vec<&str> = lib["styles"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Sky band", "Sky fill", "Sky outline"], "{lib}"); + // The file's gradient, as the document opened from it has its swatch. + open(&mut s, "skies.ai", &file, json!({})).unwrap(); + let sky = match s.doc().unwrap().doc.swatch("Sky").map(|w| w.paint.clone()) { + Some(vectorcraft_color::Paint::Gradient(g)) => g.gradient, + other => panic!("{other:?}"), + }; + // Applied to a small and a large rectangle, each gradient is fitted to the rectangle it paints. + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + for (style, (x, size)) in [("Sky band", (10, 20)), ("Sky fill", (40, 200)), ("Sky outline", (10, 120))] { + let rect = json!({"x": x, "y": x, "width": size, "height": size / 2}); + let id = NodeId(s.execute("shape.rectangle", &rect).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": [style], "apply": true, "ids": [id.0]})).unwrap(); + assert_eq!(r["added"], json!([style]), "{r}"); + let look = &s.doc().unwrap().doc.node(id).unwrap().appearance; + let (paint, angle, stroked) = match style { + "Sky outline" => (look.stroke().map(|st| &st.paint), 90.0, true), + "Sky band" => (look.fill().map(|f| &f.paint), 45.0, false), + _ => (look.fill().map(|f| &f.paint), 0.0, false), + }; + let Some(vectorcraft_color::Paint::Gradient(g)) = paint else { panic!("{style}: {look:?}") }; + assert_eq!((&g.gradient, g.geom, g.angle), (&sky, None, angle), "{style}"); + assert_eq!(look.stroke().is_some(), stroked, "{style}: {look:?}"); + if stroked { + assert!(look.fill().is_none() && look.stroke().is_some_and(|st| st.width == 2.0), "{look:?}"); + } + } +} + /// A `.ai` symbol library (symbols and their list, no layers) opens with its symbols, and Other /// Library… in the Symbols panel lists them in the list's order. #[test] diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index 0adb06132a..37adcf438d 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -184,15 +184,17 @@ impl Sets { /// boxes it overlaps, not every open one (many objects stacked in a column, sharing their /// horizontal extent, would otherwise compare every pair). An open box inside a later box of /// its group, ending no further right, is closed early, and a box inside an open box of its group, -/// ending no further right, isn't opened: whatever overlaps it overlaps that box. Fails only when -/// cancelled ([`vectorcraft_doc::cancel`]). +/// ending no further right, isn't opened: whatever overlaps it overlaps that box. An open box that +/// a later box of its group overlaps, starting lower and ending no further right or lower, keeps +/// only its part above the later box's top, so boxes staggered right and down each meet only the +/// one before. Fails only when cancelled ([`vectorcraft_doc::cancel`]). pub(super) fn overlapping(bounds: &[Option]) -> Result>, Cancelled> { Ok(sweep(bounds, None)?.groups()) } /// [`overlapping`]'s sweep. With `see_through`, two boxes are joined only when one of them is -/// see-through, and an open box is closed early, or a box isn't opened, only inside a see-through -/// box. The open +/// see-through, an open box is closed early, or a box isn't opened, only inside a see-through box, +/// and an open box is cut short only by a see-through box that is opened. The open /// see-through boxes and the open boxes without transparency are then in trees of their own, and /// a box without transparency meets only the see-through ones. fn sweep(bounds: &[Option], see_through: Option<&[bool]>) -> Result { @@ -285,12 +287,20 @@ fn sweep(bounds: &[Option], see_through: Option<&[bool]>) -> Result], joins: impl Fn(usize, usize) -> bool) -> Vec> { + let bounds: Vec> = bounds.iter().map(|b| b.filter(finite)).collect(); let n = bounds.len(); let mut group: Vec = (0..n).collect(); let hit = |a: &Rect, b: &Rect| a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1; @@ -731,9 +743,9 @@ mod tests { ]; let see = [false, true, true, false]; let mut sets = sweep(&bounds, Some(&see)).unwrap(); - // The second box meets the first; the third meets the second; the fourth meets the second - // and the third. - assert_eq!(sets.meets, 4); + // The second box meets the first; the third meets the second, which keeps only its part + // above the third's top; the fourth meets the third. + assert_eq!(sets.meets, 3); assert_eq!(sets.groups(), vec![vec![0, 1, 2, 3]]); // Opaque boxes alone meet nothing. let mut sets = sweep(&bounds, Some(&[false; 4])).unwrap(); @@ -741,6 +753,86 @@ mod tests { assert_eq!(sets.groups().len(), 4); } + /// `n` boxes of one size, each a little right of and below the one before, all overlapping and + /// none inside another. + fn staggered(n: usize) -> Vec> { + let w = 2.0 * n as f64; + (0..n).map(|i| Some(Rect::new(i as f64, i as f64, w + i as f64, w + i as f64))).collect() + } + + /// Each box of a stagger right and down meets only the box before it, which keeps only its + /// part above the new box's top, not every box still open. + #[test] + fn staggered_boxes_meet_only_the_box_before() { + let n = 512; + let bounds = staggered(n); + let mut sets = sweep(&bounds, None).unwrap(); + assert_eq!(sets.meets, n - 1); + assert_eq!(sets.groups(), vec![(0..n).collect::>()]); + // See-through boxes do the same with transparency. + let mut sets = sweep(&bounds, Some(&vec![true; n])).unwrap(); + assert_eq!(sets.meets, n - 1); + assert_eq!(sets.groups(), vec![(0..n).collect::>()]); + } + + /// An open box keeps the part a later box would meet without meeting the box that cut it short: + /// a part lower than that box, a part further right, or any part when that box is opaque. + #[test] + fn a_box_is_cut_short_only_where_the_later_box_covers_it() { + let k = Rect::new(5.0, 5.0, 15.0, 15.0); + // Lower than `k`: the last box overlaps only the bottom of the first. + let lower = [Some(Rect::new(0.0, 0.0, 10.0, 20.0)), Some(k), Some(Rect::new(8.0, 17.0, 9.0, 25.0))]; + assert_eq!(overlapping(&lower).unwrap(), vec![vec![0, 1, 2]]); + // Further right than `k`: the last box starts right of it and overlaps the first. + let right = [Some(Rect::new(0.0, 0.0, 20.0, 10.0)), Some(k), Some(Rect::new(17.0, 7.0, 25.0, 9.0))]; + assert_eq!(overlapping(&right).unwrap(), vec![vec![0, 1, 2]]); + // An opaque `k` doesn't join the opaque last box, which joins the see-through first one. + let opaque = [Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(k), Some(Rect::new(6.0, 8.0, 7.0, 20.0))]; + let mut sets = sweep(&opaque, Some(&[true, false, false])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1, 2]]); + // A see-through `k` held by a see-through box around it isn't opened and cuts nothing short. + let held = [Some(Rect::new(0.0, 2.0, 30.0, 30.0)), Some(Rect::new(1.0, 0.0, 10.0, 10.0)), Some(k), Some(Rect::new(6.0, 8.0, 7.0, 20.0))]; + let see = [true, false, true, false]; + let mut sets = sweep(&held, Some(&see)).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1, 2, 3]]); + assert_eq!(sets.groups(), every_pair_joining(&held, |i, j| see[i] || see[j])); + } + + /// Staggers in all four directions, of mixed sizes (empty ones included) with gaps and shared + /// edges, in either order, with signed zeros and boxes that aren't finite, some see-through, + /// group as comparing every pair groups them. + #[test] + fn staggered_boxes_group_as_comparing_every_pair() { + let mut seed = 0x3c6e_f372_fe94_f82bu64; + let mut next = |m: f64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % 1000) as f64 / 1000.0 * m + }; + for round in 0..48 { + let n = 8 + round * 2; + let (sx, sy) = (if round % 2 == 0 { 1.0 } else { -1.0 }, if round / 2 % 2 == 0 { 1.0 } else { -1.0 }); + let mut bounds: Vec> = (0..n) + .map(|i| { + let step = next(4.0).floor(); + let (x, y) = (sx * (i as f64 * 2.0 + step), sy * (i as f64 * 2.0 + next(4.0).floor())); + let (w, h) = (next(8.0).floor() * 2.0, next(8.0).floor() * 2.0); + Some(Rect::new(x, y, x + w, y + h)) + }) + .collect(); + bounds[0] = Some(Rect::new(-0.0, -0.0, 0.0, 6.0)); + bounds[n / 2] = Some(Rect::new(0.0, sy * 6.0, f64::INFINITY, sy * 6.0 + 4.0)); + if round / 4 % 2 == 1 { + bounds.reverse(); + } + assert_eq!(overlapping(&bounds).unwrap(), every_pair(&bounds), "round {round}"); + let see: Vec = (0..n).map(|_| next(1.0) < 0.5).collect(); + let mut sets = sweep(&bounds, Some(&see)).unwrap(); + assert_eq!(sets.groups(), every_pair_joining(&bounds, |i, j| see[i] || see[j]), "round {round}, with transparency"); + } + } + /// An opaque box between a member and its group's top that paints over the member joins, and /// so on along a chain; one under the member stays, but for one within the fringe. #[test] diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index ced9347bc1..4d562a76e7 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -470,8 +470,8 @@ impl Library { } } -/// Make `g` a library gradient: no placement, freeform points or swatch links (a linked stop keeps -/// the color it shows), the angle its placement showed (a finite one), and stops in order with +/// Make `g` a library gradient: no placement, freeform points, swatch links or process plates (a +/// linked stop keeps the color it shows), the angle its placement showed (a finite one), and stops in order with /// offsets, opacities and midpoints in range. False when it has fewer than 2 or more than /// [`MAX_STOPS`] stops. fn clean_gradient(g: &mut GradientPaint) -> bool { @@ -490,8 +490,8 @@ fn clean_gradient(g: &mut GradientPaint) -> bool { st.offset = within(st.offset, 0.0, 1.0, 0.0); st.opacity = within(st.opacity, 0.0, 1.0, 1.0); st.midpoint = within(st.midpoint, 0.13, 0.87, 0.5); - st.swatch = None; - st.tint = 1.0; + let color = st.color; + st.set_color(color, None); } g.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); true @@ -1488,29 +1488,15 @@ fn add_to_char_styles(s: &mut Session, p: &Value) -> Result { /// Unlink a library character style's `fill` and `stroke` colors (solid, and each gradient stop) /// from swatches that are not global or spot swatches of `doc`: an unlinked color keeps the color -/// it shows at full tint, as [`clean_gradient`] keeps a library gradient's stops. A link to a +/// it shows at full tint, and an unlinked stop has no process plates, as [`clean_gradient`] keeps +/// a library gradient's stops. A link to a /// global or spot swatch `doc` has stays, and takes that swatch's color. fn unlink_missing_swatches(doc: &vectorcraft_doc::Document, attrs: &mut Map) { let keep = |name: &str| doc.global_color(name).is_some(); for key in ["fill", "stroke"] { let Some(mut paint) = attrs.get(key).and_then(|v| serde_json::from_value::(v.clone()).ok()) else { continue }; - let mut changed = false; - let mut unlink = |swatch: &mut Option, tint: &mut f32| { - if swatch.as_deref().is_some_and(|n| !keep(n)) { - *swatch = None; - *tint = 1.0; - changed = true; - } - }; - match &mut paint { - Paint::Solid { swatch, tint, .. } => unlink(swatch, tint), - Paint::Gradient(g) => { - for st in &mut g.gradient.stops { - unlink(&mut st.swatch, &mut st.tint); - } - } - _ => {} - } + // `map_links` gives an unlinked color full tint, and an unlinked stop no process plates. + let changed = paint.map_links(&mut |_, swatch, _| swatch.take_if(|n| !keep(n.as_str())).is_some()); if changed && let Ok(v) = serde_json::to_value(&paint) { attrs.insert(key.to_string(), v); } diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index 99295e5c79..fa8ef06856 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -144,9 +144,10 @@ fn find(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => None, Some(_) => { // Images and placed documents in opacity-mask art and in the art of symbols, graph - // designs and patterns are named by their ids too (`links.list`). + // designs, patterns and stored brushes are named by their ids too (`links.list`). let ids = ids_param(p, "ids", C)?.unwrap_or_default(); - let kind = |id: &NodeId| st.doc.stored_node(*id).map(|(n, _)| &n.kind); + let doc = super::links::with_brush_art(&st.doc); + let kind = |id: &NodeId| doc.stored_node(*id).map(|(n, _)| &n.kind); if let Some(value) = ids.iter().find(|id| kind(id).is_none()).map(|id| id.0) { return Err(bad(C, format!("no such object id {value}"))); } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index d37abc85c7..9a026598e8 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -27,6 +27,7 @@ use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use serde_json::{Value, json}; +use vectorcraft_brush::stored_art::StoredArt; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; use vectorcraft_doc::{ Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, @@ -44,7 +45,7 @@ pub fn specs() -> Vec { "Check Links", [], None, - "{ids?: [image or placed document ids] (default: every linked image and placed document, in the layers and in opacity-mask art, symbols, graph designs and patterns)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{ids?: [image or placed document ids] (default: every linked image and placed document, in the layers and in opacity-mask art, symbols, graph designs, patterns and the art of stored brushes)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, check ), @@ -53,7 +54,7 @@ pub fn specs() -> Vec { "Update Links", [], None, - "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. Images and placed documents inside symbols, graph designs, patterns and opacity masks have ids of their own, as links.list gives them; those that show the same content as one read again are read again too. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}", + "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. Images and placed documents inside symbols, graph designs, patterns, opacity masks and the art of stored brushes have ids of their own, as links.list gives them; those that show the same content as one read again are read again too. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}", has_doc, update ), @@ -71,7 +72,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object linked to the same file as one of them, in the layers, opacity-mask art, symbols, graph designs and patterns) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used; an error for a DCS file that reads plate files of its own), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object linked to the same file as one of them, in the layers, opacity-mask art, symbols, graph designs, patterns and the art of stored brushes) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used; an error for a DCS file that reads plate files of its own), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}", has_doc, relink ), @@ -80,7 +81,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, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs and patterns (one row per stored object, never one per symbol instance or pattern tile) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), 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), dcsTransparency?: true (with dcsTransparency), owner?: {kind: layers|symbol|graphDesign|pattern, name? (of the symbol, graph design or pattern), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs, patterns and stored brushes (one row per stored object, never one per symbol instance, pattern tile or brushed stroke) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), 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), dcsTransparency?: true (with dcsTransparency), owner?: {kind: layers|symbol|graphDesign|pattern|brush, name? (of the symbol, graph design, pattern or brush), index? (a brush's place in the Brushes panel, from 0), tile?: side|outerCorner|innerCorner|start|end (a pattern brush's tile), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -89,7 +90,7 @@ pub fn specs() -> Vec { "Go To Link", [], None, - "{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1], onCanvas: true}; one in opacity-mask art or in the art of a symbol, graph design or pattern has no place of its own on the canvas: the selection is cleared → {id, onCanvas: false, owner} (owner as links.list gives it)", + "{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1], onCanvas: true}; one in opacity-mask art or in the art of a symbol, graph design, pattern or stored brush has no place of its own on the canvas: the selection is cleared → {id, onCanvas: false, owner} (owner as links.list gives it)", has_doc, go_to ), @@ -198,6 +199,8 @@ pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec)> { /// visited before it has (art being edited on a layer and its definition) counts once, by its /// first place; without `ids` its content is still grouped. fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { + let view = with_brush_art(d); + let d = &*view; let mut out: Vec = vec![]; let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new(); // Looked up for every linked node: a set, not the list (which may repeat an id many times). @@ -234,13 +237,52 @@ fn stored(d: &Document, id: NodeId) -> Option<&Node> { d.stored_node(id).map(|(n, _)| &**n) } +/// `d` with the art of its stored brushes set on it ([`vectorcraft_brush::stored_art`]), so that +/// the images and placed documents in that art are visited and found by their ids as stored art; +/// `d` itself when no stored brush's art holds one or the art is set on it already. +pub(crate) fn with_brush_art(d: &Document) -> Cow<'_, Document> { + if !d.mounted_art.is_empty() { + return Cow::Borrowed(d); + } + let art = StoredArt::read(d); + if art.is_empty() { + return Cow::Borrowed(d); + } + let mut d = d.clone(); + art.mount(&mut d); + Cow::Owned(d) +} + +/// `f` run on `d` with the art of its stored brushes set on it as [`with_brush_art`] sets it; the +/// brushes whose art `f` changed are then written back into the library, the color masks of its +/// images made for their pixels first. The ids it gives that art are the ones [`with_brush_art`] +/// gives it in the same document. +pub(crate) fn editing_brush_art(d: &mut Document, f: impl FnOnce(&mut Document) -> T) -> T { + if !d.mounted_art.is_empty() { + return f(d); + } + let art = StoredArt::read(d); + if art.is_empty() { + return f(d); + } + art.mount(d); + let before: Vec> = d.mounted_art.iter().map(|(n, _)| n.clone()).collect(); + let out = f(d); + if d.mounted_art.len() != before.len() || d.mounted_art.iter().zip(&before).any(|((n, _), b)| !Arc::ptr_eq(n, b)) { + super::refresh_color_masks(None, d); + } + art.write_back(d); + out +} + /// Of `ids`, the objects of `d` outside the layer tree (where [`Document::node`] finds none). fn nested_ids(d: &Document, ids: &[NodeId]) -> std::collections::HashSet { ids.iter().copied().filter(|id| d.node(*id).is_none()).collect() } /// `links.list`'s `owner` of an object stored at `place` of `d`, when it is not in the layer tree -/// itself: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`. +/// itself: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`, or for one in the art of a +/// stored brush, `{kind: brush, name, index, tile?, maskOf?}`. fn owner_json(d: &Document, place: StoredPlace) -> Option { if place.in_layers() { return None; @@ -250,6 +292,7 @@ fn owner_json(d: &Document, place: StoredPlace) -> Option { StoredRoot::Symbol(i) => json!({ "kind": "symbol", "name": d.symbols.get(i).map(|s| s.name.as_str()) }), StoredRoot::GraphDesign(i) => json!({ "kind": "graphDesign", "name": d.graph_designs.get(i).map(|g| g.name.as_str()) }), StoredRoot::Pattern { pattern, .. } => json!({ "kind": "pattern", "name": d.patterns.get(pattern).map(|p| p.name.as_str()) }), + StoredRoot::Mounted(i) => d.mounted_art.get(i).map_or_else(|| json!({}), |(_, owner)| owner.clone()), }; if let Some(m) = place.mask_of { owner["maskOf"] = json!(m.0); @@ -479,7 +522,8 @@ pub(crate) fn linked_files(d: &Document, base: LinkBase) -> Vec { /// again (Preferences › Use Low Resolution Proxy for Linked EPS keeps the preview on screen only). pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a, Document>, Vec) { let mut keys = BTreeSet::new(); - d.visit_images(|_, im| { + // The art of the stored brushes draws its images too. + with_brush_art(d).visit_images(|_, im| { if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) { keys.insert(im.key.clone()); } @@ -763,7 +807,7 @@ struct Nested<'a> { impl Nested<'_> { /// Every root of `d`: the layers (whose own objects are left alone), then the art of symbols, - /// graph designs and patterns. + /// graph designs and patterns, then [`Document::mounted_art`]. fn run(mut self, d: &mut Document) { let first = d.peek_next_id(); self.next = first; @@ -774,7 +818,7 @@ impl Nested<'_> { for root in d.symbols.iter_mut().map(|s| &mut s.art).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) { self.root(root); } - for root in d.patterns.iter_mut().flat_map(|p| &mut p.art) { + for root in d.patterns.iter_mut().flat_map(|p| &mut p.art).chain(d.mounted_art.iter_mut().map(|(n, _)| n)) { self.root(root); } for _ in first..self.next { @@ -1306,8 +1350,14 @@ impl Resolved { /// found away from their path take the new path. A link nothing is known of whose image, or placed /// document, shows a preview (a file another app wrote kept only that) was never read: its file is /// read now, into the object's bounds, which isn't a change to report. A placed document whose -/// file's bytes are gone isn't showing a preview. Nothing here is an undo step. +/// file's bytes are gone isn't showing a preview. Links in the art of the stored brushes count too, +/// and that art keeps the ids they are listed by. Nothing here is an undo step. pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps: bool) -> Resolved { + editing_brush_art(d, |d| resolve_links(d, base, update, low_res_proxy_eps)) +} + +/// [`resolve`] in the art `d` stores. +fn resolve_links(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps: bool) -> Resolved { let mut out = Resolved::default(); // The image (with its preview) and hash of each file read for a preview here, by path: images // of other keys linking to the same file share them, rather than each reading, decoding and @@ -1458,8 +1508,8 @@ fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) { } /// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open -/// documents, in the layers, symbols, graph designs, patterns and opacity masks: read again from -/// the new file, each as one Update Links step of its document. +/// documents, in the layers, symbols, graph designs, patterns, opacity masks and the art of the +/// stored brushes: read again from the new file, each as one Update Links step of its document. pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) { let path = absolute_path(path); let targets: Vec = s @@ -1469,7 +1519,7 @@ pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) { .filter(|(_, st)| st.uid != saved) .filter(|(_, st)| { let mut placed = false; - st.doc.visit_placed(|_, p| placed |= p.link.path == path); + with_brush_art(&st.doc).visit_placed(|_, p| placed |= p.link.path == path); placed }) .map(|(i, _)| i) @@ -1526,7 +1576,7 @@ fn link_target(link: &LinkInfo, base: LinkBase) -> PathBuf { /// `d` (looking from `base`) as saved to `dest`: the links' paths relative to its folder written /// in; `None` when they are already. A link whose file is not at its path is written relative to /// where its file is found, else to the place its relative path names. Links in the layers, -/// symbols, graph designs, patterns and opacity masks are all set. +/// symbols, graph designs, patterns, opacity masks and the art of the stored brushes are all set. pub fn with_relative_paths(d: &Document, base: LinkBase, dest: &str) -> Option { let dest = absolute_path(dest); let dir = Path::new(&dest).parent()?; @@ -1535,23 +1585,29 @@ pub fn with_relative_paths(d: &Document, base: LinkBase, dest: &str) -> Option); @@ -1706,7 +1762,9 @@ fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) - let updated: Vec = reads.iter().flat_map(|(g, _)| ids_json(&g.ids)).collect(); let warnings = spot_swatch_notes(&st.doc, reads.iter().map(|(_, read)| read)); if !reads.is_empty() { - s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g))))?; + s.edit("Update Links", |d, _| { + editing_brush_art(d, |d| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g)))) + })?; } let mut out = json!({ "updated": updated, "missing": missing }); if !warnings.is_empty() { @@ -1833,9 +1891,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(no_folder)?) } }; + let doc = with_brush_art(&st.doc); // An image or a placed document, with its link. let image = |id: &NodeId| { - stored(&st.doc, *id).and_then(|n| match &n.kind { + stored(&doc, *id).and_then(|n| match &n.kind { NodeKind::Image(im) => Some(im.link.as_ref()), NodeKind::PlacedDocument(p) => Some(Some(&p.link)), _ => None, @@ -1865,7 +1924,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { let paths: BTreeSet<&str> = ids.iter().filter_map(|id| image(id).flatten()).map(|l| l.path.as_str()).collect(); let mut given: BTreeSet = ids.iter().copied().collect(); let mut more = vec![]; - st.doc.visit_stored(|n, _| { + doc.visit_stored(|n, _| { if let Some((l, _, _)) = linked(n) && paths.contains(l.path.as_str()) && n.id.0 != 0 @@ -1876,7 +1935,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { }); ids.extend(more); } - if copy_to.is_some() && ids.iter().any(|id| matches!(stored(&st.doc, *id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { + if copy_to.is_some() && ids.iter().any(|id| matches!(stored(&doc, *id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { return Err(bad(C, "copy takes images only: relink placed documents where their files are")); } // (ids, the file) per file to read. @@ -1912,7 +1971,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { let mut images = vec![]; let mut documents: Vec<((u32, bool, PageBox), Vec)> = vec![]; for id in ids { - match stored(&st.doc, id).map(|n| &n.kind) { + match stored(&doc, id).map(|n| &n.kind) { Some(NodeKind::PlacedDocument(pl)) => { let k = (pl.page(), pl.bounding, pl.page_box); match documents.iter_mut().find(|(e, _)| *e == k) { @@ -1963,9 +2022,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { } } let relinked: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); + drop(doc); let warnings = spot_swatch_notes(&s.doc()?.doc, reads.iter().map(|(_, read)| read)); if !reads.is_empty() { - s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None)))?; + s.edit("Relink", |d, _| editing_brush_art(d, |d| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None))))?; } not_found.sort(); not_found.dedup(); @@ -2075,14 +2135,15 @@ fn selected_images(st: &crate::DocState, keep: impl Fn(&ImageObject) -> bool) -> st.selection.objects.iter().copied().filter(|id| image_of(&st.doc, *id).is_some_and(&keep)).collect() } -/// `id`, else the first selected image. -fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { +/// `id` (looked up in `doc`, the document of `st` as [`with_brush_art`] gives it), else the first +/// selected image. +fn image_param(st: &crate::DocState, doc: &Document, p: &Value, cmd: &str) -> Result { let id = object_param(p, "id", cmd)? .or_else(|| selected_images(st, |_| true).first().copied()) .ok_or_else(|| bad(cmd, "select an image, or pass id"))?; - match image_of(&st.doc, id) { + match image_of(doc, id) { Some(_) => Ok(id), - None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)), + None if stored(doc, id).is_none() => Err(crate::EngineError::NoNode(id)), None => Err(bad(cmd, format!("object {} is not an image", id.0))), } } @@ -2105,13 +2166,14 @@ fn placement_of(d: &Document, id: NodeId) -> Option { } } -/// `id`, else the first selected image or placed document. -fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { - let first = || st.selection.objects.iter().copied().find(|id| placement_of(&st.doc, *id).is_some()); +/// `id` (looked up in `doc`, the document of `st` as [`with_brush_art`] gives it), else the first +/// selected image or placed document. +fn linked_param(st: &crate::DocState, doc: &Document, p: &Value, cmd: &str) -> Result { + let first = || st.selection.objects.iter().copied().find(|id| placement_of(doc, *id).is_some()); let id = object_param(p, "id", cmd)?.or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; - match placement_of(&st.doc, id) { + match placement_of(doc, id) { Some(_) => Ok(id), - None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)), + None if stored(doc, id).is_none() => Err(crate::EngineError::NoNode(id)), None => Err(bad(cmd, format!("object {} is not an image or a linked file", id.0))), } } @@ -2218,10 +2280,12 @@ fn list(s: &mut Session, p: &Value) -> Result { let interactions = if switch_or(p, "dcsTransparency", false, C)? { dcs_transparency(&st.doc) } else { BTreeSet::new() }; let statuses = statuses(st); // The layer tree's objects, top first as the Layers panel lists them, then the others in the - // order they are stored (opacity-mask art, symbols, graph designs, patterns), each once. + // order they are stored (opacity-mask art, symbols, graph designs, patterns, the art of the + // stored brushes), each once. + let doc = with_brush_art(&st.doc); let (mut objects, mut nested) = (vec![], vec![]); let mut seen = std::collections::HashSet::new(); - st.doc.visit_stored(|n, place| { + doc.visit_stored(|n, place| { if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) && n.id.0 != 0 && seen.insert(n.id) { if place.in_layers() { objects.push((n, place)) } else { nested.push((n, place)) } } @@ -2273,7 +2337,7 @@ fn list(s: &mut Session, p: &Value) -> Result { if preview { row["preview"] = json!(true); } - if let Some(owner) = owner_json(&st.doc, place) { + if let Some(owner) = owner_json(&doc, place) { row["owner"] = owner; } if interactions.contains(&n.id) { @@ -2295,10 +2359,13 @@ fn list(s: &mut Session, p: &Value) -> Result { fn go_to(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let id = linked_param(st, p, "links.goTo")?; - // An object in mask art or a definition has no place of its own on the canvas: the selection - // is cleared, and the reply names what holds it. - if let Some(owner) = st.doc.stored_node(id).and_then(|(_, place)| owner_json(&st.doc, place)) { + let doc = with_brush_art(&st.doc); + let id = linked_param(st, &doc, p, "links.goTo")?; + // An object in mask art, a definition or a stored brush's art has no place of its own on the + // canvas: the selection is cleared, and the reply names what holds it. + let owner = doc.stored_node(id).and_then(|(_, place)| owner_json(&doc, place)); + drop(doc); + if let Some(owner) = owner { s.select(|_, sel| sel.set([]))?; return Ok(json!({ "id": id.0, "onCanvas": false, "owner": owner })); } @@ -2310,7 +2377,8 @@ fn go_to(s: &mut Session, p: &Value) -> Result { fn embed(s: &mut Session, p: &Value) -> Result { const C: &str = "links.embed"; let st = s.doc()?; - let kind = |id: &NodeId| stored(&st.doc, *id).map(|n| &n.kind); + let doc = with_brush_art(&st.doc); + let kind = |id: &NodeId| stored(&doc, *id).map(|n| &n.kind); let linked = |id: &NodeId| match kind(id) { Some(NodeKind::Image(im)) => im.link.is_some(), Some(NodeKind::PlacedDocument(_)) => true, @@ -2362,17 +2430,20 @@ fn embed(s: &mut Session, p: &Value) -> Result { _ => missing.extend(g.ids), } } + drop(doc); if !done.is_empty() || !documents.is_empty() { s.edit("Embed", |d, _| { - for (ids, read) in reads { - install(d, &ids, read, Some(Preserve::Bounds), None)?; - } - for id in &done { - if let Some(Node { kind: NodeKind::Image(im), .. }) = d.stored_node_mut(*id) { - im.link = None; + editing_brush_art(d, |d| { + for (ids, read) in reads { + install(d, &ids, read, Some(Preserve::Bounds), None)?; } - } - documents.iter().try_for_each(|id| super::place::document::expand(d, *id, C)) + for id in &done { + if let Some(Node { kind: NodeKind::Image(im), .. }) = d.stored_node_mut(*id) { + im.link = None; + } + } + documents.iter().try_for_each(|id| super::place::document::expand(d, *id, C)) + }) })?; } done.extend(documents); @@ -2395,9 +2466,10 @@ fn convert_image(bytes: &[u8], ext: &str) -> std::result::Result, String fn unembed(s: &mut Session, p: &Value) -> Result { const C: &str = "links.unembed"; let st = s.doc()?; - let id = image_param(st, p, C)?; - let n = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?; - let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?; + let doc = with_brush_art(&st.doc); + let id = image_param(st, &doc, p, C)?; + let n = stored(&doc, id).ok_or(crate::EngineError::NoNode(id))?; + let im = image_of(&doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?; if im.link.is_some() { return Err(bad(C, format!("image {} is linked already", id.0))); } @@ -2422,7 +2494,8 @@ fn unembed(s: &mut Session, p: &Value) -> Result { let img = linked_image(&bytes, Some(&path))?; write_file(&path, &bytes)?; let read = (link_info(&path, &bytes), img); - s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds), None))?; + drop(doc); + s.edit("Unembed", |d, _| editing_brush_art(d, |d| install(d, &[id], read, Some(Preserve::Bounds), None)))?; Ok(json!({ "id": id.0, "path": path })) } @@ -2480,9 +2553,10 @@ fn scale_rotation(xf: Affine, (px, py): (f64, f64)) -> (Value, Value) { (json!([a.hypot(b) / px * 100.0, c.hypot(d) / py * 100.0]), json!((-b).atan2(a).to_degrees() + 0.0)) } -/// Link Info of placed document `id`. -fn placed_info(st: &crate::DocState, id: NodeId) -> Result { - let n = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?; +/// Link Info of placed document `id` (looked up in `doc`, the document of `st` as +/// [`with_brush_art`] gives it). +fn placed_info(st: &crate::DocState, doc: &Document, id: NodeId) -> Result { + let n = stored(doc, id).ok_or(crate::EngineError::NoNode(id))?; let NodeKind::PlacedDocument(pl) = &n.kind else { return Err(bad("links.info", format!("object {} is not a placed document", id.0))) }; let size = n.geometric_bounds().unwrap_or_default(); let (scale, rotation) = scale_rotation(pl.xf, (1.0, 1.0)); @@ -2507,13 +2581,19 @@ fn placed_info(st: &crate::DocState, id: NodeId) -> Result { fn info(s: &mut Session, p: &Value) -> Result { const C: &str = "links.info"; - let id = linked_param(s.doc()?, p, C)?; - if image_of(&s.doc()?.doc, id).is_none() { - return placed_info(s.doc()?, id); - } + let id = { + let st = s.doc()?; + let doc = with_brush_art(&st.doc); + let id = linked_param(st, &doc, p, C)?; + if image_of(&doc, id).is_none() { + return placed_info(st, &doc, id); + } + id + }; let mut out = super::place::image_info(s, &json!({ "id": id.0 }))?; let st = s.doc()?; - let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?; + let doc = with_brush_art(&st.doc); + let im = image_of(&doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?; let (px, py) = file_pt(&st.doc, &im.key); out["effectivePpi"] = out["ppi"].take(); out["ppi"] = json!([72.0 / px, 72.0 / py]); @@ -2530,9 +2610,10 @@ fn info(s: &mut Session, p: &Value) -> Result { fn placement_options(s: &mut Session, p: &Value) -> Result { const C: &str = "links.placementOptions"; let st = s.doc()?; + let doc = with_brush_art(&st.doc); let ids = - ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect()); - if let Some(id) = ids.iter().find(|id| placement_of(&st.doc, **id).is_none()) { + ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&doc, *id).is_some()).collect()); + if let Some(id) = ids.iter().find(|id| placement_of(&doc, **id).is_none()) { return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); } if ids.is_empty() { @@ -2551,7 +2632,8 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => None, Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "clip must be true or false"))?), }; - let first = ids.first().and_then(|id| placement_of(&st.doc, *id)).unwrap_or_default(); + let first = ids.first().and_then(|id| placement_of(&doc, *id)).unwrap_or_default(); + drop(doc); if preserve.is_none() && align.is_none() && clip.is_none() { return Ok(json!({ "placement": placement_json(first) })); } @@ -2561,11 +2643,13 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { o.clip = clip.unwrap_or(o.clip); }; s.edit("Placement Options", |d, _| { - for id in &ids { - if let Some(o) = d.stored_node_mut(*id).and_then(placement_mut) { - set(o); + editing_brush_art(d, |d| { + for id in &ids { + if let Some(o) = d.stored_node_mut(*id).and_then(placement_mut) { + set(o); + } } - } + }); Ok(()) })?; let mut shown = first; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 8d6f53d55b..315a737c59 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -229,7 +229,7 @@ impl<'a> Collector<'a> { Some((d, base)) => super::links::linked_files(d, base), None => super::links::linked_files(doc, LinkBase::file(Some(source))), }; - doc.visit_placed(|_, p| { + super::links::with_brush_art(doc).visit_placed(|_, p| { let page = (p.link.page.unwrap_or(1), p.bounding, p.page_box); let pages = placed.entry(p.link.path.clone()).or_default(); if !pages.contains(&page) { @@ -389,16 +389,20 @@ impl<'a> Collector<'a> { } if self.opts.relink { // The child files have already been rewritten; the hash and size of each - // target must reflect the *packaged* bytes rather than the original. - doc.update_links(|link: &mut LinkInfo| { - if let Some(copy) = local.get(&link.path) { - let relative = relative_in_package(destination, ©.name); - link.path = self.root.as_ref().map_or_else(|| copy.name.clone(), |r| r.join(©.name).to_string_lossy().into_owned()); - link.relative = Some(relative); - link.size = copy.size; - link.hash = copy.hash.clone(); - link.modified = None; - } + // target must reflect the *packaged* bytes rather than the original. The links in the + // art of the stored brushes are written back into the brush library. + let root = &self.root; + super::links::editing_brush_art(doc, |doc| { + doc.update_links(|link: &mut LinkInfo| { + if let Some(copy) = local.get(&link.path) { + let relative = relative_in_package(destination, ©.name); + link.path = root.as_ref().map_or_else(|| copy.name.clone(), |r| r.join(©.name).to_string_lossy().into_owned()); + link.relative = Some(relative); + link.size = copy.size; + link.hash = copy.hash.clone(); + link.modified = None; + } + }) }); } if let Some(s) = self.sections.get_mut(section) { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 9d7b42cca2..b497a9f096 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -315,13 +315,13 @@ fn set_handle(s: &mut Session, p: &Value) -> Result { let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?); let independent = switch_or(p, "independent", false, "path.setHandle")?; // A handle already there, on an anchor already of the kind it would get, changes nothing and - // records no undo step. - if let Some(a) = anchor_of(s, id, si, ai)? { - let mut b = a; - b.set_handle(out, pos, independent); - if b == a { - return ok(); - } + // records no undo step. The handle is compared directly: `set_handle` would recompute the + // other handle of a smooth anchor, which need not give back the same value. + if let Some(a) = anchor_of(s, id, si, ai)? + && (if out { a.h_out } else { a.h_in }) == pos + && !(independent && a.kind != AnchorKind::Corner) + { + return ok(); } s.edit("Reshape", |d, _| { let path = path_mut(d, id)?; diff --git a/crates/engine/src/cmd/perspgrid.rs b/crates/engine/src/cmd/perspgrid.rs index d13e7d8c52..aaeee36d80 100644 --- a/crates/engine/src/cmd/perspgrid.rs +++ b/crates/engine/src/cmd/perspgrid.rs @@ -19,7 +19,7 @@ use vectorcraft_tools::distort::perspective::widget::{WidgetCorner, WidgetPlace} use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid}; use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; -use super::distortcmds::{grid_of, silent, store_grid}; +use super::distortcmds::{grid_kept, grid_of, silent, store_grid}; use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, str_param, switch_or, unique_name}; pub fn specs() -> Vec { @@ -261,10 +261,21 @@ pub(crate) fn grid_preset(s: &mut Session, p: &Value) -> Result { let g = s.perspective_preset_grid(&name, ab).ok_or_else(|| bad(C, format!("no perspective grid preset named `{name}`")))?; // The objects stay attached and the view options stay as they were. let g = PerspectiveGrid { attached: old.attached, locked: old.locked, lock_station: old.lock_station, snap: old.snap, rulers: old.rulers, ..g }; - s.edit("Perspective Grid Preset", |d, _| { - store_grid(d, &g); - Ok(()) - })?; + // When the document holds this grid already, apart from whether it shows and its active plane, + // the preset only sets those two, without an undo step (as perspective.grid.show and + // perspective.plane.set do). + let view = PerspectiveGrid { visible: old.visible, plane: old.plane, ..g.clone() }; + if !grid_kept(&s.doc()?.doc, &view) { + s.edit("Perspective Grid Preset", |d, _| { + store_grid(d, &g); + Ok(()) + })?; + } else if (old.visible, old.plane) != (g.visible, g.plane) { + silent(s, |cur| { + cur.visible = g.visible; + cur.plane = g.plane; + })?; + } Ok(g.definition_json()) } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 20f19ef803..a558545e4e 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -962,8 +962,10 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result { (None, [id]) => *id, _ => return Err(bad(C, "select one image or pass id")), }; - // An image in opacity-mask art or a definition's art is found by its id too (`links.list`). - let n = st.doc.stored_node(id).map(|(n, _)| &**n).ok_or(EngineError::NoNode(id))?; + // An image in opacity-mask art, a definition's art or a stored brush's art is found by its id + // too (`links.list`). + let doc = super::links::with_brush_art(&st.doc); + let n = doc.stored_node(id).map(|(n, _)| &**n).ok_or(EngineError::NoNode(id))?; let NodeKind::Image(im) = &n.kind else { return Err(bad(C, format!("object {} is not an image", id.0))) }; // Effective resolution: 72 pt per inch over the points one pixel spans along each axis. let [a, b, c, d, ..] = im.xf.as_coeffs(); diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 8e4afaa295..8b00e95152 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -330,7 +330,8 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool) } /// `d` for output: linked images and placed documents that keep only their previews get their -/// files back, else output as their previews, with a warning. +/// files back, else output as their previews, with a warning. The art of the stored brushes counts +/// too. pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { let (d, mut warnings) = super::super::links::full_linked_images(d, Default::default()); let doc = match &d { @@ -339,7 +340,7 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { }; let mut need: Vec = vec![]; let mut seen = std::collections::HashSet::new(); - doc.visit_placed(|_, p| { + super::super::links::with_brush_art(doc).visit_placed(|_, p| { if doc.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && seen.insert(p.key.clone()) { need.push(p.clone()); } @@ -370,7 +371,7 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { "{n} placed documents output as their previews: their files can't be found, or changed since they were read (relink or update them)" ), }); - out.placed_as_previews(&previews); + super::super::links::editing_brush_art(&mut out, |out| out.placed_as_previews(&previews)); } (Cow::Owned(out), warnings) } diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 3fa082bdb0..867f256b4c 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -51,6 +51,7 @@ use vectorcraft_doc::{Effect, Node}; use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects, registry, safe_mode}; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; +use crate::store::StoreTurn; use vectorcraft_plugins::permissions::{self, Grant, GrantToken, Permission}; @@ -683,6 +684,9 @@ thread_local! { /// Tests: runs once in the next `plugin.install` on this thread after it installs and before it /// writes the store. pub(crate) static BEFORE_STORE: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// Tests: runs once on this thread when [`keep_installed`] has written the module to the store + /// and before it writes the grant. + pub(crate) static BEFORE_GRANT_STORED: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; /// Tests: runs once on this thread when [`hold_plugin_changes`] finds the lock held and waits. pub(crate) static WHILE_WAITING: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; } @@ -718,7 +722,9 @@ pub(crate) fn hold_plugin_changes() -> std::sync::MutexGuard<'static, ()> { /// the next start → its `plugin.list` entry. A refused install changes neither the plug-in store /// nor the plug-ins and grants of the process. A module that lists no permissions ends any grant /// held for its id. A `plugin.remove` in another session waits until the install has written the -/// store. +/// store. With a plug-in store, all of it happens in a turn on the store ([`StoreTurn`]), so another +/// process using the same folder changes it before or after; when the store can't be locked, nothing +/// is installed. fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "plugin.install"; // One use: spent whatever this install does. @@ -736,8 +742,11 @@ fn install(s: &mut Session, p: &Value) -> Result { { return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed")); } - // A removal in another session waits until the store holds what this install wrote. + // A removal in another session waits until the store holds what this install wrote, and so + // does a change of the store by another process. let _held = hold_plugin_changes(); + let turn = + s.plugin_store_turn().map_err(|e| EngineError::Other(format!("{C}: {}: {e}: nothing was installed", crate::store::STORE_NOT_LOCKED)))?; // The grant the checks below count on, the approval included. The install checks it again, // with the plug-in installed under the id, before anything is stored: another session may // change either in between. @@ -844,41 +853,12 @@ fn install(s: &mut Session, p: &Value) -> Result { } // The plug-in gets the settings and cache kept for its id. A module without permissions takes // the id over, so nothing the user approved for the id applies any more. - let installed = registry::install_checked(plugin, update, check, || s.attach_plugin_storage(&id), err)?; + let installed = registry::install_checked(plugin, update, check, || s.attach_plugin_storage(&turn, &id), err)?; #[cfg(test)] if let Some(between) = BEFORE_STORE.take() { between(); } - // Installed plug-ins stay installed after a restart (#2541). The module is written first and its - // grant only once it is there; when the grant can't be written, the store gets the module it - // held back. A failed write of that module is only logged: the store then holds the new module, - // which the grant doesn't cover. A write that reports a failure may still have put the old - // module back, so the store is read again to tell which module it holds. A stored module that - // can't be read back isn't replaced by one that needs a grant. - let previous = s.stored_plugin_bytes(&id); - let mut stored = (previous.is_ok() || !asks) && s.store_plugin(&id, &bytes); - let holds_new = |s: &Session| s.stored_plugin_bytes(&id).is_ok_and(|held| held.as_deref() == Some(&bytes[..])); - if asks { - if stored && !s.store_grant(&id, Some(g)) { - stored = !s.put_back_plugin(&id, previous.ok().flatten().as_deref()) && holds_new(s); - } - } else { - // When the write failed, the old stored module stays in the folder and needs its grant at - // the next launch. A grant that can't be removed still approves the old bytes, so the store - // gets the module it held back. When that module can't be read or written back, or there - // was none, no module goes with the grant, and the grant stops counting for every session in - // the process attached to the folder. A grants file that is too large or doesn't hold a list - // approves nothing, and the new module stays; one that can't be opened or read may approve - // the old bytes once it reads again. - let writes = s.grant_writes(); - if stored && !s.store_grant(&id, None) && s.holds_plugin_grant(&id) { - let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(&id, held.as_deref())); - stored = !back && holds_new(s); - if !back || matches!(previous, Ok(None)) { - s.ignore_stored_grant(&id, writes); - } - } - } + let stored = keep_installed(s, &turn, &id, &bytes, asks.then_some(g)); // Installing a disabled plug-in again (an updated version) turns it on for the next launch too, // also when it is disabled by its stored file alone (it stopped a launch while loading). When // the new version could not replace the stored file, the old one stays disabled. @@ -891,8 +871,9 @@ fn install(s: &mut Session, p: &Value) -> Result { } } if changed { - s.store_disabled_plugins(); + s.store_disabled_plugins_in(&turn); } + drop(turn); let mut v = describe_installed(&installed); if !unknown.is_empty() { v["unknownCommands"] = json!(unknown); @@ -900,6 +881,53 @@ fn install(s: &mut Session, p: &Value) -> Result { Ok(v) } +/// Keeps the plug-in `id` that `plugin.install` installed, its module `bytes`, in the plug-in store, +/// in `turn`, with `grant` when its module lists permissions (`None`: it lists none, and the grant +/// held for the id is removed) → whether the store now holds the new module (`false` without a +/// store). +/// +/// Installed plug-ins stay installed after a restart (#2541). The module is written first and its +/// grant only once it is there; when the grant can't be written, the store gets the module it held +/// back. A failed write of that module is only logged: the store then holds the new module, which +/// the grant doesn't cover. A write that reports a failure may still have put the old module back, +/// so the store is read again to tell which module it holds. A stored module that can't be read back +/// isn't replaced by one that needs a grant. +pub(crate) fn keep_installed(s: &Session, turn: &StoreTurn, id: &str, bytes: &[u8], grant: Option) -> bool { + let asks = grant.is_some(); + let previous = s.stored_plugin_bytes(id); + let mut stored = (previous.is_ok() || !asks) && s.store_plugin(turn, id, bytes); + #[cfg(test)] + if let Some(between) = BEFORE_GRANT_STORED.take() { + between(); + } + let holds_new = |s: &Session| s.stored_plugin_bytes(id).is_ok_and(|held| held.as_deref() == Some(bytes)); + match grant { + Some(g) => { + if stored && !s.store_grant_in(turn, id, Some(g)) { + stored = !s.put_back_plugin(turn, id, previous.ok().flatten().as_deref()) && holds_new(s); + } + } + None => { + // When the write failed, the old stored module stays in the folder and needs its grant + // at the next launch. A grant that can't be removed still approves the old bytes, so the + // store gets the module it held back. When that module can't be read or written back, or + // there was none, no module goes with the grant, and the grant stops counting for every + // session in the process attached to the folder. A grants file that is too large or + // doesn't hold a list approves nothing, and the new module stays; one that can't be + // opened or read may approve the old bytes once it reads again. + let writes = s.grant_writes(); + if stored && !s.store_grant_in(turn, id, None) && s.holds_plugin_grant(id) { + let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(turn, id, held.as_deref())); + stored = !back && holds_new(s); + if !back || matches!(previous, Ok(None)) { + s.ignore_stored_grant(id, writes); + } + } + } + } + stored +} + /// `plugin.inspect`: what installing the module would install, without installing it, whether that /// needs the user to grant its permissions first (`needsApproval`) and whether it would take /// another module's place (`needsReplace`). @@ -992,15 +1020,17 @@ fn load_folder(s: &Session, folder: &str) -> Value { None if crate::file_access::current().is_none() => Ok(()), None => Err(format!("automation path rejected: {} isn't valid text", f.display())), }; - // A `plugin.install` in another session finishes writing the store first. + // A `plugin.install` in another session or process finishes writing the store first. When the + // store can't be locked, the folder still loads, and the store isn't written. let _held = hold_plugin_changes(); + let turn = s.plugin_store_turn().unwrap_or_else(|_| StoreTurn::unheld()); match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_folder_approver()) { Ok(r) => { for id in &r.loaded { - s.attach_plugin_storage(id); + s.attach_plugin_storage(&turn, id); } if r.disabled_changed { - s.store_disabled_plugins(); + s.store_disabled_plugins_in(&turn); } json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::>()}) } @@ -1140,6 +1170,9 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { (None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")), }; let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect(); + // The list is changed and saved in one turn on the plug-in store, so that an install in another + // process doesn't change the store in between. + let turn = s.plugin_store_turn().ok(); if enabled && !safe_mode::disabled().iter().any(|i| items.iter().any(|item| i.same(item))) { return Err(bad(C, "that plug-in isn't disabled (see plugin.safeMode)")); } @@ -1149,7 +1182,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { } // Without a plug-in folder (a session confined to automation roots), or when the list there // can't take the change, the change stays in this process and goes with the next change that saves. - let saved = !changed || s.store_disabled_plugins(); + let saved = !changed || turn.as_ref().is_some_and(|turn| s.store_disabled_plugins_in(turn)); Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(shown_item).collect::>()})) } @@ -1164,31 +1197,35 @@ fn info(_: &mut Session, p: &Value) -> Result { fn remove(s: &mut Session, p: &Value) -> Result { let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?; - // An install in another session finishes its store writes first. + // An install in another session or process finishes its store writes first. When the store + // can't be locked, nothing is removed. let _held = hold_plugin_changes(); + let turn = s + .plugin_store_turn() + .map_err(|e| EngineError::Other(format!("plugin.remove: {}: {e}: nothing was removed", crate::store::STORE_NOT_LOCKED)))?; let installed = registry::get(id).is_some(); // Settings and cache the store keeps for an id that isn't installed are deleted too. let stored = !installed && vectorcraft_plugins::manifest::valid_id(id) && s.has_plugin_storage(id); // Installed again, it asks again. A grant that can't be removed still approves the module, and // so may a grants file that can't be opened or read. One that is too large or doesn't hold a // list approves nothing and doesn't stop the removal. - let forget = || s.store_grant(id, None) || !s.holds_plugin_grant(id); + let forget = || s.store_grant_in(&turn, id, None) || !s.holds_plugin_grant(id); // Its settings and cache are moved aside and its grant removed before anything is deleted: when // either can't be done, the plug-in stays installed, as it was, and a second `plugin.remove` // tries again. let failed = if installed || stored { - s.remove_plugin_storage(id, forget).err().map(|e| e.to_string()) + s.remove_plugin_storage(&turn, id, forget).err().map(|e| e.to_string()) } else { (!forget()).then(|| crate::store::GRANT_NOT_REMOVED.to_string()) }; if let Some(e) = failed { if installed { - s.attach_plugin_storage(id); + s.attach_plugin_storage(&turn, id); } return Err(EngineError::Other(e)); } registry::revoke(id); - s.unstore_plugin(id); + s.unstore_plugin(&turn, id); if !stored && (!installed || !registry::remove(id)) { return Err(err(vectorcraft_plugins::Error::NotFound(id.into()))); } @@ -1248,7 +1285,7 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; a refused install keeps the grant, a failed write of the module to the plug-in store leaves the grant in the store, and a stored module that is disabled or not read in Safe Mode keeps the grant for its id; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash is already recorded, by a person's approval or by a grant accepted under that flag, needs no grant), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session (a module whose commandAllowlist names a command no plug-in may call, such as one that reads or writes files, is refused) → its plugin.list entry, plus unknownCommands?: [id] (commandAllowlist ids no command has; a call of one fails)", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; a refused install keeps the grant, a failed write of the module to the plug-in store leaves the grant in the store, and a stored module that is disabled or not read in Safe Mode keeps the grant for its id; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash is already recorded, by a person's approval or by a grant accepted under that flag, needs no grant), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session (with a plug-in store, the install waits while another window or process changes the store and writes the module and its grant before that window or process changes it again; when the store's disabled.lock can't be locked, nothing is installed) (a module whose commandAllowlist names a command no plug-in may call, such as one that reads or writes files, is refused) → its plugin.list entry, plus unknownCommands?: [id] (commandAllowlist ids no command has; a call of one fails)", always, install ), @@ -1266,7 +1303,7 @@ pub fn specs() -> Vec { "Remove Plug-in", [], None, - "{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, or its grant can't be removed from the plug-in store, the command fails and the plug-in stays installed; when the settings can't be moved back either, the error names the folder they stay in, and the next load, install or removal of the plug-in moves them back); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}", + "{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, or its grant can't be removed from the plug-in store, the command fails and the plug-in stays installed; when the settings can't be moved back either, the error names the folder they stay in, and the next load, install or removal of the plug-in moves them back); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it; it waits while another window or process changes the plug-in store, and when the store's disabled.lock can't be locked, the command fails and nothing is removed → {removed}", always, remove ), diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 09b478e8c7..bc06f197e5 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -62,7 +62,7 @@ pub fn specs() -> Vec { "Other Library…", ["Window", "Graphic Style Libraries"], None, - "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library) → {library: id, name, count}", + "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library; a .ai or EPS style painting with one of the file's gradients by name takes it at the style's angle, fitted to each object it is applied to, VectorCraft's own placement) → {library: id, name, count}", always, load ), diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 3b98cbd9a6..3388b8344b 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "New Swatch", ["Window", "Swatches"], None, - "{name?, color? | colors?: [colour] (one swatch each, in one undo step; #rrggbbaa and #rgba are refused) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour; lab: CIE L*a*b*, how spot colours are usually defined), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped), none?: true (the None paint), paint?: paint.setFill params, keepModel?: bool (as paint.setFill)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. A gradient swatch holds no link to the swatch its gradient came from, and a pattern swatch holds the pattern untransformed. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\", \"L=55 a=60 b=40\") → {name, names: [every new swatch]}", + "{name?, color? | colors?: [colour] (one swatch each, in one undo step; #rrggbbaa and #rgba are refused) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour; lab: CIE L*a*b*, how spot colours are usually defined), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped), none?: true (the None paint), paint?: paint.setFill params, keepModel?: bool (as paint.setFill)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. A gradient swatch keeps the gradient's aspect (with or without start/end) and holds no link to the swatch its gradient came from, and a pattern swatch holds the pattern untransformed. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\", \"L=55 a=60 b=40\") → {name, names: [every new swatch]}", has_doc, swatch_new ), @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Swatch Options", ["Window", "Swatches"], None, - "{name, newName?, color? (e.g. {l, a, b} for a Lab colour; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill; the swatch's own color and a color given with mode keep their model), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour; gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}", + "{name, newName?, color? (e.g. {l, a, b} for a Lab colour; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill; the swatch's own color and a color given with mode keep their model), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient} or {swatch}) or {pattern: name} replacing the swatch's colour; gradient or pattern with one of the same kind (stored as swatch.new stores it: a gradient keeps its aspect with or without start/end and holds no swatch link; a pattern is untransformed)} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}", has_doc, swatch_edit ), @@ -187,11 +187,7 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { })), Paint::None => Err(bad(C, "a swatch needs a colour, gradient or pattern")), _ if spot || mode.is_some() || group.is_some() => Err(bad(C, "spot, mode and group apply to solid colours only")), - // The swatch holds the gradient itself, not a link to the swatch it may have come from. - // It keeps `geom`, which holds the aspect the swatch is applied with. - Paint::Gradient(g) => Ok(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { swatch: None, ..*g }))), - // The swatch holds the pattern untransformed, not the placement of the art it came from. - Paint::Pattern { pattern, .. } => Ok(pattern_paint(&pattern)), + paint @ (Paint::Gradient(_) | Paint::Pattern { .. }) => Ok(stored_paint(paint, p)), }) .collect::>>()?; // A name applies to a single new swatch. @@ -220,6 +216,27 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": names[0], "names": names })) } +/// A gradient or pattern `paint`, parsed from the paint params `p`, as a new or edited swatch +/// stores it. +fn stored_paint(paint: Paint, p: &Value) -> Paint { + match paint { + // A gradient given an `aspect` but no vector is placed on a box so that the swatch keeps + // the aspect, as `paint.setFill` places it on each object. + Paint::Gradient(ref g) if g.geom.is_none() && g.freeform.is_none() => { + match super::gradient::place_paint(&paint, p, Some(vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0))) { + Paint::Gradient(g) => Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { swatch: None, ..*g })), + other => other, + } + } + // The swatch holds the gradient itself, not a link to the swatch it may have come from. + // It keeps `geom`, which holds the aspect the swatch is applied with. + Paint::Gradient(g) => Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { swatch: None, ..*g })), + // The swatch holds the pattern untransformed, not the placement of the art it came from. + Paint::Pattern { pattern, .. } => pattern_paint(&pattern), + other => other, + } +} + /// The `names` list and `name` parameters, without repeats; at least one is required. pub(super) fn names_param(p: &Value, cmd: &str) -> Result> { let mut seen: HashSet = HashSet::new(); @@ -866,12 +883,21 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result { } // A replacement paint of the swatch's kind (a dropped colour or edited gradient). let paint = match p.get("paint").filter(|v| !v.is_null()) { - Some(v) => match (if given("mode") { paint_from(s, &keeping_model(v))? } else { paint_from(s, v)? }, &sw.paint) { + Some(v) => match ( + // A pattern by name, as `swatch.new` takes it. + match str_param(v, "pattern") { + Some(pat) if d.pattern(pat).is_some() => Some(pattern_paint(pat)), + Some(pat) => return Err(bad(C, format!("no pattern `{pat}`"))), + None if given("mode") => paint_from(s, &keeping_model(v))?, + None => paint_from(s, v)?, + }, + &sw.paint, + ) { (Some(Paint::Solid { color, .. }), Paint::Solid { .. }) => Some(Paint::solid(color)), - (Some(Paint::Gradient(g)), Paint::Gradient(_)) => { - Some(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { geom: None, swatch: None, ..*g }))) + // Stored as `swatch.new` stores it. + (Some(new @ Paint::Gradient(_)), Paint::Gradient(_)) | (Some(new @ Paint::Pattern { .. }), Paint::Pattern { .. }) => { + Some(stored_paint(new, v)) } - (Some(new @ Paint::Pattern { .. }), Paint::Pattern { .. }) => Some(new), _ => return Err(bad(C, format!("`{name}` takes a paint of its own kind (colour, gradient or pattern)"))), }, None => None, diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index b3fab4a2bc..73e2a78b31 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Vertical", ["Type", "Orientation"], None, - "{ids?|id?} set vertical writing for the selected type and every frame of its threads → {vertical, ids}", + "{ids?|id?} set vertical writing for the selected type and every frame of its threads; no undo step when every object is already vertical → {vertical, ids}", has_selection, |s, p| orientation(s, p, true) ), @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Horizontal", ["Type", "Orientation"], None, - "{ids?|id?} set horizontal writing for the selected type and every frame of its threads → {vertical, ids}", + "{ids?|id?} set horizontal writing for the selected type and every frame of its threads; no undo step when every object is already horizontal → {vertical, ids}", has_selection, |s, p| orientation(s, p, false) ), @@ -59,7 +59,7 @@ pub fn specs() -> Vec { "Optical Margin Alignment", ["Type"], None, - "{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting): in every paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both; Auto aligns a right-to-left line at its right; type on a path hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered → {on, ids}", + "{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting): in every paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both; Auto aligns a right-to-left line at its right; type on a path hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered; no undo step when every object already has the setting → {on, ids}", has_selection, optical_margin_alignment ), @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "System Layout", [], None, - "{on?: bool} turn System Layout on or off for the document (on omitted: the opposite of the document's setting) and every text object in it. On, each character that takes any space advances by a whole number of points, at least one, so small type does not run together; a character with no width stays at none. Off (the default, and what a document from before it reads as), spacing uses fractions of a point. New point, area and path type inherits the document's setting → {on}", + "{on?: bool} turn System Layout on or off for the document (on omitted: the opposite of the document's setting) and every text object in it. On, each character that takes any space advances by a whole number of points, at least one, so small type does not run together; a character with no width stays at none. Off (the default, and what a document from before it reads as), spacing uses fractions of a point. New point, area and path type inherits the document's setting. No undo step when the document and every text object already have the setting → {on}", has_doc, system_layout ), @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Fractional Widths", [], None, - "{ids?|id?, on?: bool} turn Fractional Widths on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting). On (the default, and what a document from before it reads as), spacing between characters uses fractions of a point. Off, each character that takes any space advances by a whole number of points, at least one, so small type does not run together; a character with no width stays at none. The setting is the whole type object, not a range of characters → {on, ids}", + "{ids?|id?, on?: bool} turn Fractional Widths on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting). On (the default, and what a document from before it reads as), spacing between characters uses fractions of a point. Off, each character that takes any space advances by a whole number of points, at least one, so small type does not run together; a character with no width stays at none. The setting is the whole type object, not a range of characters. No undo step when every object already has the setting → {on, ids}", has_selection, fractional_widths ), @@ -157,24 +157,30 @@ pub fn specs() -> Vec { } /// Type › Type Orientation: set vertical or horizontal writing on every selected text object and -/// every frame of their threads, in one undo step. +/// every frame of their threads, in one undo step (none when nothing changes). fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result { let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Text Orientation")?); - s.edit("Text Orientation", |d, _| { + // When every object already has the orientation, the document and its undo history stay as they are. + super::colorcmds::edit_if_changed(s, "Text Orientation", |_, d| { + let mut changed = false; for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)); }; - t.vertical = vertical; - refresh_bounds(t); + if t.vertical != vertical { + t.vertical = vertical; + refresh_bounds(t); + changed = true; + } } - Ok(()) + Ok(((), changed)) })?; Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::>()})) } /// Type › Optical Margin Alignment: set `on` (without it, the opposite of the first object's -/// setting) on every selected text object and every frame of their threads, in one undo step. +/// setting) on every selected text object and every frame of their threads, in one undo step +/// (none when nothing changes). fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result { let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Optical Margin Alignment")?); let first = ids.first().and_then(|id| match s.doc().ok()?.doc.node(*id).map(|n| &n.kind) { @@ -182,29 +188,38 @@ fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result { _ => None, }); let on = switch_or(p, "on", !first.unwrap_or(false), "type.opticalMarginAlignment")?; - s.edit("Optical Margin Alignment", |d, _| { + // When every object already has the setting, the document and its undo history stay as they are. + super::colorcmds::edit_if_changed(s, "Optical Margin Alignment", |_, d| { + let mut changed = false; for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)); }; - t.optical_margin = on; - refresh_bounds(t); + if t.optical_margin != on { + t.optical_margin = on; + refresh_bounds(t); + changed = true; + } } - Ok(()) + Ok(((), changed)) })?; Ok(json!({"on": on, "ids": ids.iter().map(|id| id.0).collect::>()})) } /// Character panel menu › System Layout: set `on` (without it, the opposite of the document's -/// setting) on the document and every text object, in one undo step. Off is the default. +/// setting) on the document and every text object, in one undo step (none when nothing changes). +/// Off is the default. fn system_layout(s: &mut Session, p: &Value) -> Result { let current = s.doc()?.doc.setup.system_layout; let on = switch_or(p, "on", !current, "type.systemLayout")?; - s.edit("System Layout", |d, _| { + // When the document and every text object already have the setting, the document and its undo + // history stay as they are. + super::colorcmds::edit_if_changed(s, "System Layout", |_, d| { + let mut changed = d.setup.system_layout != on; d.setup.system_layout = on; let mut ids = vec![]; d.walk(|n| { - if matches!(n.kind, NodeKind::Text(_)) { + if matches!(&n.kind, NodeKind::Text(t) if t.system_layout != on) { ids.push(n.id); } }); @@ -214,15 +229,16 @@ fn system_layout(s: &mut Session, p: &Value) -> Result { }; t.system_layout = on; refresh_bounds(t); + changed = true; } - Ok(()) + Ok(((), changed)) })?; Ok(json!({ "on": on })) } /// Character panel menu › Fractional Widths: set `on` (without it, the opposite of the first /// object's setting) on every selected text object and every frame of their threads, in one undo -/// step. On is the default. +/// step (none when nothing changes). On is the default. fn fractional_widths(s: &mut Session, p: &Value) -> Result { let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Fractional Widths")?); let first = ids.first().and_then(|id| match s.doc().ok()?.doc.node(*id).map(|n| &n.kind) { @@ -230,15 +246,20 @@ fn fractional_widths(s: &mut Session, p: &Value) -> Result { _ => None, }); let on = switch_or(p, "on", !first.unwrap_or(true), "type.fractionalWidths")?; - s.edit("Fractional Widths", |d, _| { + // When every object already has the setting, the document and its undo history stay as they are. + super::colorcmds::edit_if_changed(s, "Fractional Widths", |_, d| { + let mut changed = false; for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)); }; - t.fractional_widths = on; - refresh_bounds(t); + if t.fractional_widths != on { + t.fractional_widths = on; + refresh_bounds(t); + changed = true; + } } - Ok(()) + Ok(((), changed)) })?; Ok(json!({"on": on, "ids": ids.iter().map(|id| id.0).collect::>()})) } diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 35365cd713..36a0f965b7 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -199,8 +199,11 @@ fn optical_margin_alignment_is_set_per_object_and_saved_only_when_on() { assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s)); assert_eq!(serde_json::to_value(text(&s)).unwrap()["opticalMarginAlignment"], true); // `on` sets it; without `on` it toggles. + // Setting the value the type already has: no undo step. + let n = s.doc().unwrap().history.undo.len(); assert_eq!(s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap()["on"], true); assert!(text(&s).optical_margin); + assert_eq!(s.doc().unwrap().history.undo.len(), n); assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false); assert!(!text(&s).optical_margin); assert_eq!(left(&s), inside); @@ -247,6 +250,10 @@ fn system_layout_sets_the_document_and_every_text_object() { assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); assert_eq!(serde_json::to_value(setup(&s)).unwrap()["systemLayout"], true); assert_eq!(serde_json::to_value(text(&s, id)).unwrap()["systemLayout"], true); + // Setting the value the document and its type already have: no undo step. + let n = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("type.systemLayout", &json!({"on": true})).unwrap()["on"], true); + assert_eq!(s.doc().unwrap().history.undo.len(), n); // New type inherits the document's setting. let made = s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Z"})).unwrap()["id"].as_u64().unwrap(); assert!(text(&s, made).system_layout); @@ -305,6 +312,10 @@ fn fractional_widths_is_set_per_object_and_saved_only_when_off() { assert!(text(&s).fractional_widths); assert_eq!(s.execute("type.fractionalWidths", &json!({"on": false})).unwrap()["on"], false); assert!(!text(&s).fractional_widths); + // Setting the value the type already has: no undo step. + let n = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("type.fractionalWidths", &json!({"on": false})).unwrap()["on"], false); + assert_eq!(s.doc().unwrap().history.undo.len(), n); // One undo step. s.execute("edit.undo", &json!({})).unwrap(); assert!(text(&s).fractional_widths); @@ -2331,8 +2342,17 @@ fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() { let tall = bounds(&s); assert!(tall.height() > tall.width(), "{tall:?}"); assert!((tall.center().x - 100.0).abs() < 6.0, "the anchor stays on the column's centre line: {tall:?}"); + // Vertical type set vertical again: no undo step. + let n = s.doc().unwrap().history.undo.len(); + s.execute("type.orientation.vertical", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + assert_eq!(bounds(&s), tall); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(bounds(&s), wide); + // Horizontal type set horizontal: no undo step either. + let n = s.doc().unwrap().history.undo.len(); + s.execute("type.orientation.horizontal", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); } #[test] diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 8a18ce9824..9c555d82f7 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -17,9 +17,8 @@ //! * **Plug-ins**: the module of each plug-in `plugin.install` installs, as a `.wasm` file named //! after its id (as Illustrator keeps plug-ins in its Plug-ins folder); `plugin.remove` deletes it. //! Attaching the folder installs them again, except in Safe Mode and except the ones the user -//! disabled, which `disabled.json` in the same folder lists (`plugin.setEnabled`; a window locks -//! `disabled.lock` while it writes the list). The Additional Plug-ins Folder preference is not -//! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins +//! disabled, which `disabled.json` in the same folder lists (`plugin.setEnabled`). The Additional +//! Plug-ins Folder preference is not copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins //! stored there that list permissions (id, module hash, permissions); a stored plug-in whose //! permissions it doesn't grant is skipped with a log record, and stays in the folder. //! * **Plug-in settings and caches**: `storage/id-/` in the plug-in store, the id written as @@ -27,6 +26,11 @@ //! keeps with the `settings` and `cache` permissions ([`PluginStorage`]): `settings.json` and a //! `cache` folder with one file per entry. The host names every file; a plug-in only names keys. //! Replacing a plug-in keeps them, and `plugin.remove` deletes them. +//! * **Turns on the plug-in store**: a window or process locks `disabled.lock` in the plug-in folder +//! ([`StoreTurn`]) while it reads and changes the stored plug-ins, `grants.json`, `disabled.json` +//! and the plug-ins' settings and caches, so that two of them using one folder change it one after +//! the other: an install's module and grant are written in one turn, and a grant written for one +//! plug-in doesn't undo another one's. //! //! The files are untrusted input like any other: sizes and counts are capped, a file that does not //! parse is skipped with a log record, and every write is atomic. @@ -62,12 +66,15 @@ pub const MAX_PLUGIN_STORE: u64 = 512 << 20; const PLUGIN_EXT: &str = "wasm"; /// The file in the plug-in folder that lists the plug-ins the user disabled. pub const DISABLED_FILE: &str = "disabled.json"; -/// The file in the plug-in folder that a window locks while it reads and writes [`DISABLED_FILE`]. +/// The file in the plug-in folder that a window locks while it reads and changes the plug-in store +/// ([`StoreTurn`]), [`DISABLED_FILE`] included. pub const DISABLED_LOCK: &str = "disabled.lock"; /// The file in the plug-in store that holds the permissions the user granted. const GRANTS_FILE: &str = "grants.json"; /// The error of a `plugin.remove` that can't remove the plug-in's grant from [`GRANTS_FILE`]. pub(crate) const GRANT_NOT_REMOVED: &str = "the plug-in's grant can't be removed from the plug-in store"; +/// The error of a change to the plug-in store that can't take its turn ([`StoreTurn`]). +pub(crate) const STORE_NOT_LOCKED: &str = "the plug-in store can't be locked"; /// The largest grants file read. const MAX_GRANTS_FILE: u64 = 1 << 20; /// The folder in the plug-in store that holds each plug-in's settings and cache. @@ -84,7 +91,7 @@ const CACHE_EXT: &str = "bin"; /// A write to a folder that holds more is refused as full. pub(crate) const MAX_CACHE_SCAN: usize = storage::MAX_CACHE_ENTRIES * 2; /// Held while a plug-in's settings or cache change, so two runs can't pass a cap together or lose -/// each other's settings. +/// each other's settings. Taken after the plug-in store's turn ([`StoreTurn`]), never before it. static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); /// The state of each plug-in folder's storage, shared by every storage made for the folder until /// the plug-in is removed, which closes it and drops it. @@ -99,7 +106,7 @@ const MAX_GRANT_WRITES_KEPT: usize = 256; struct IgnoredGrants { /// The grants that should be gone but could not be removed: no session in the process counts /// them ([`Session::ignore_stored_grant`]) until one writes the store's grants for the id - /// ([`Session::store_grant`]). + /// ([`Session::store_grant_in`]). ignored: BTreeSet<(PathBuf, String)>, /// The latest grant writes of the process, at most [`MAX_GRANT_WRITES_KEPT`], each with the /// count of writes it made ([`IgnoredGrants::writes`]). @@ -217,23 +224,53 @@ fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> { Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system")) } -/// [`DISABLED_LOCK`] in `dir`, locked until the file is dropped, waiting while another window holds -/// it; `None` when it can't be opened or locked. +/// [`DISABLED_LOCK`] in the plug-in store `dir`, locked until the file is dropped, waiting while +/// another window or process holds it, or another handle in this process. The file is opened for +/// reading and writing (a handle that only appends can't be locked on Windows) and is never +/// replaced or removed, so that every window locks the same file. #[cfg(not(target_arch = "wasm32"))] -fn lock_disabled(dir: &Path) -> Option { - let opened = std::fs::create_dir_all(dir) - .and_then(|()| std::fs::File::options().create(true).truncate(false).read(true).write(true).open(dir.join(DISABLED_LOCK))); - let file = opened.and_then(|f| f.lock().map(|()| f)); - if let Err(e) = &file { - log::warn!("can't lock {}: {e}", dir.join(DISABLED_LOCK).display()); +fn lock_store(dir: &Path) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let file = std::fs::File::options().create(true).truncate(false).read(true).write(true).open(dir.join(DISABLED_LOCK))?; + match file.try_lock() { + Ok(()) => return Ok(file), + Err(std::fs::TryLockError::WouldBlock) => {} + Err(std::fs::TryLockError::Error(e)) => return Err(e), } - file.ok() + #[cfg(test)] + if let Some(waiting) = WHILE_STORE_LOCKED.take() { + waiting(); + } + file.lock()?; + Ok(file) } /// The browser has no folders to keep anything in. #[cfg(target_arch = "wasm32")] -fn lock_disabled(_: &Path) -> Option { - None +fn lock_store(_: &Path) -> std::io::Result { + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system")) +} + +/// A turn on a plug-in store ([`Session::plugin_store_turn`]): while it is held, no other window or +/// process that takes one reads and changes the store's plug-ins, grants, disabled list, settings +/// or caches. Dropping it ends the turn. A turn is taken once per operation and handed to the +/// functions it calls, since a second lock of the same file by the same thread waits for ever. +pub(crate) struct StoreTurn { + /// [`DISABLED_LOCK`], locked; `None` without a store, and for a load that goes ahead without + /// a turn ([`Session::plugin_store_turn_for_load`]), which then writes nothing to the store. + lock: Option, +} + +impl StoreTurn { + /// A turn that isn't held, for a load that goes ahead without one: it writes nothing. + pub(crate) fn unheld() -> Self { + StoreTurn { lock: None } + } + + /// Whether the store may be written in this turn. + fn held(&self) -> bool { + self.lock.is_some() + } } #[cfg(test)] @@ -252,6 +289,15 @@ thread_local! { /// Tests: writing the `.delete` file beside a folder about to be deleted ([`delete_aside`]) /// fails on this thread, as on a full volume. pub(crate) static MARKER_WRITE_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: runs once on this thread when locking the plug-in store ([`lock_store`]) finds it + /// locked and waits. + pub(crate) static WHILE_STORE_LOCKED: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// Tests: runs once on this thread when a grant write has read `grants.json` and before it + /// writes the file. + pub(crate) static AFTER_GRANTS_READ: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// Tests: runs once on this thread when a change of a plug-in's settings has read them and + /// before it writes them. + pub(crate) static AFTER_SETTINGS_READ: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; } /// Remove `dir/file` → whether it is gone; a file that isn't there is what was wanted. @@ -401,11 +447,14 @@ impl Session { /// Keep the plug-ins this session installs in `dir`, take the disabled plug-ins saved there /// ([`DISABLED_FILE`]), and install the stored ones ([`Session::load_stored_plugins`]). pub fn attach_plugin_store(&mut self, dir: PathBuf) { - let disabled = - read_capped(&dir.join(DISABLED_FILE), safe_mode::MAX_DISABLED_FILE_BYTES).map(|b| safe_mode::parse_disabled_file(&b)).unwrap_or_default(); - safe_mode::set_disabled(disabled); + let path = dir.join(DISABLED_FILE); self.plugin_dir = Some(dir); - self.load_stored_plugins(); + // A `plugin.install` in another session or process writes the store before this reads it. + let _held = crate::cmd::plugin::hold_plugin_changes(); + let turn = self.plugin_store_turn_for_load(); + let disabled = read_capped(&path, safe_mode::MAX_DISABLED_FILE_BYTES).map(|b| safe_mode::parse_disabled_file(&b)).unwrap_or_default(); + safe_mode::set_disabled(disabled); + self.load_stored_plugins_in(&turn); } /// Install the plug-ins kept in the plug-in folder: none in Safe Mode, and none the user @@ -415,14 +464,25 @@ impl Session { /// stays in the folder with its grant. A grant for an id whose stored module lists no /// permissions is forgotten; one that can't be removed from the grants file doesn't count for /// any session in the process attached to the folder until the file can be written for the id - /// ([`Session::ignore_stored_grant`]). + /// ([`Session::ignore_stored_grant`]). The folder is read and changed in one turn on it + /// ([`Session::plugin_store_turn_for_load`]). pub fn load_stored_plugins(&mut self) { + if self.plugin_dir.is_none() || safe_mode::active() { + return; + } + // A `plugin.install` in another session or process writes the store before this reads it. + let _held = crate::cmd::plugin::hold_plugin_changes(); + let turn = self.plugin_store_turn_for_load(); + self.load_stored_plugins_in(&turn); + } + + /// [`Session::load_stored_plugins`] in `turn`, while [`crate::cmd::plugin::hold_plugin_changes`] + /// is held. + fn load_stored_plugins_in(&mut self, turn: &StoreTurn) { let Some(dir) = self.plugin_dir.clone() else { return }; if safe_mode::active() { return; } - // A `plugin.install` in another session writes the store before this reads it. - let _held = crate::cmd::plugin::hold_plugin_changes(); let grants = self.counted_grants(&dir); let limits = vectorcraft_plugins::Limits::default(); let mut left = MAX_PLUGIN_STORE; @@ -454,7 +514,7 @@ impl Session { Some(g) => { asks_nothing.remove(&m.id); vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone())); - vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id)); + vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id, turn)); if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) { log::warn!("skipping the stored plug-in {}: {e}", path.display()); } @@ -466,7 +526,7 @@ impl Session { } Ok(plugin) => { asks_nothing.insert(plugin.id().to_string()); - vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id())); + vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id(), turn)); if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) { log::warn!("skipping the stored plug-in {}: {e}", path.display()); } @@ -483,13 +543,34 @@ impl Session { }; if named { let writes = self.grant_writes(); - if !self.store_grant(id, None) && self.holds_plugin_grant(id) { + if !self.store_grant_in(turn, id, None) && self.holds_plugin_grant(id) { self.ignore_stored_grant(id, writes); } } } } + /// Take a turn on the attached plug-in store ([`StoreTurn`]), waiting while another window or + /// process holds one; without a store, a turn that writes nothing. An error, with a log record, + /// when the lock file can't be made, opened or locked. Taken after + /// [`crate::cmd::plugin::hold_plugin_changes`] when both are needed, and never by a thread that + /// holds a turn already. + pub(crate) fn plugin_store_turn(&self) -> std::io::Result { + let Some(dir) = self.plugin_dir.as_deref() else { return Ok(StoreTurn::unheld()) }; + lock_store(dir) + .map(|file| StoreTurn { lock: Some(file) }) + .inspect_err(|e| log::warn!("can't lock {}: {e}", dir.join(DISABLED_LOCK).display())) + } + + /// [`Session::plugin_store_turn`] for a load, which goes ahead without the turn when the store + /// folder isn't there (nothing is stored) or can't be locked, and then writes nothing to it. + pub(crate) fn plugin_store_turn_for_load(&self) -> StoreTurn { + match self.plugin_dir.as_deref() { + Some(dir) if dir.is_dir() => self.plugin_store_turn().unwrap_or_else(|_| StoreTurn::unheld()), + _ => StoreTurn::unheld(), + } + } + /// Write the disabled plug-ins saved for the next launch to the plug-in folder → whether they /// went there, every change included (`false` without a folder, and when a plug-in turned off /// found the file's list full: [`safe_mode::Merged::complete`]). The changes this launch made are applied to the list @@ -499,8 +580,15 @@ impl Session { /// a file that can't be read (too large, say) or that a later version wrote /// ([`safe_mode::read_disabled_file`]): it is kept as it is. pub fn store_disabled_plugins(&self) -> bool { + self.plugin_store_turn().is_ok_and(|turn| self.store_disabled_plugins_in(&turn)) + } + + /// [`Session::store_disabled_plugins`] in `turn`; `false` when it isn't held. + pub(crate) fn store_disabled_plugins_in(&self, turn: &StoreTurn) -> bool { let Some(dir) = self.plugin_dir.as_deref() else { return false }; - let Some(_turn) = lock_disabled(dir) else { return false }; + if !turn.held() { + return false; + } let path = dir.join(DISABLED_FILE); let theirs = match read_capped(&path, safe_mode::MAX_DISABLED_FILE_BYTES) { Ok(bytes) => match safe_mode::read_disabled_file(&bytes) { @@ -580,10 +668,10 @@ impl Session { grants } - /// Write the module `bytes` of the plug-in `id` to the store → whether they went there (`false` - /// without a folder). - pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) -> bool { - self.plugin_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes)) + /// Write the module `bytes` of the plug-in `id` to the store in `turn` → whether they went there + /// (`false` without a folder, and when `turn` isn't held). + pub(crate) fn store_plugin(&self, turn: &StoreTurn, id: &str, bytes: &[u8]) -> bool { + turn.held() && self.plugin_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes)) } /// The module the store holds for the plug-in `id`: `None` without a folder or such a file, and @@ -601,14 +689,15 @@ impl Session { /// Return the store's module for the plug-in `id` to `previous`, the bytes it held before a /// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed. → whether the /// write reported success; a write that reports a failure may still have put `previous` back. - pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) -> bool { + /// In `turn`, the one the write was made in. + pub(crate) fn put_back_plugin(&self, turn: &StoreTurn, id: &str, previous: Option<&[u8]>) -> bool { #[cfg(test)] if PUT_BACK_FAILS.get() { return false; } let back = match previous { - Some(bytes) => self.store_plugin(id, bytes), - None => self.unstore_plugin(id), + Some(bytes) => self.store_plugin(turn, id, bytes), + None => self.unstore_plugin(turn, id), }; #[cfg(test)] if PUT_BACK_FAILS_AFTER_WRITE.get() { @@ -623,15 +712,23 @@ impl Session { self.plugin_dir.as_ref().map(|dir| dir.join(format!("{}.{PLUGIN_EXT}", file_stem(id))).display().to_string()) } + /// Tests: [`Session::store_grant_in`] in a turn of its own ([`Session::plugin_store_turn`]); + /// `false` when it can't be taken. + #[cfg(test)] + pub(crate) fn store_grant(&self, id: &str, grant: Option) -> bool { + self.plugin_store_turn().is_ok_and(|turn| self.store_grant_in(&turn, id, grant)) + } + /// Record in the store what the user granted plug-in `id` (`None`: nothing), leaving the store's /// other grants as they are; with none left, remove the file. Entries this version can't use /// are written back as they were, except one for `id`. A grants file that is too large, can't /// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded; - /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. → whether the store now - /// holds what was asked (`false` without a folder). When it does, the grant kept for `id` counts - /// again, for every session in the process attached to the store ([`Session::ignore_stored_grant`]). - pub(crate) fn store_grant(&self, id: &str, grant: Option) -> bool { - let done = self.write_grant(id, grant); + /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. The file is read and + /// written in `turn`. → whether the store now holds what was asked (`false` without a folder, + /// and when `turn` isn't held). When it does, the grant kept for `id` counts again, for every + /// session in the process attached to the store ([`Session::ignore_stored_grant`]). + pub(crate) fn store_grant_in(&self, turn: &StoreTurn, id: &str, grant: Option) -> bool { + let done = turn.held() && self.write_grant(id, grant); if done && let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); let entry = (key, id.to_string()); @@ -648,7 +745,7 @@ impl Session { done } - /// How many grant writes the sessions of the process have made ([`Session::store_grant`]). + /// How many grant writes the sessions of the process have made ([`Session::store_grant_in`]). /// Taken before a write that may fail, for [`Session::ignore_stored_grant`]. pub(crate) fn grant_writes(&self) -> u64 { IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).writes @@ -661,7 +758,7 @@ impl Session { } /// Stop counting the attached store's grant for the plug-in `id`, in every session in the - /// process attached to the same folder, until [`Session::store_grant`] next succeeds for it in + /// process attached to the same folder, until [`Session::store_grant_in`] next succeeds for it in /// one of them: for a grant that should be gone and could not be removed, which would otherwise /// approve the bytes it covers. `writes` is [`Session::grant_writes`] from before the failed /// write: when a session has written the store's grants for `id` since, the file holds what that @@ -687,6 +784,10 @@ impl Session { return false; } }; + #[cfg(test)] + if let Some(between) = AFTER_GRANTS_READ.take() { + between(); + } let others = file.other.len(); file.other.retain(|entry| entry.get("id").and_then(serde_json::Value::as_str) != Some(id)); let changed = match grant { @@ -724,16 +825,17 @@ impl Session { }) } - /// Remove the plug-in `id` from the store → whether its file is gone (`false` without a folder). - pub(crate) fn unstore_plugin(&self, id: &str) -> bool { - self.plugin_dir.as_deref().is_some_and(|dir| take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)))) + /// Remove the plug-in `id` from the store in `turn` → whether its file is gone (`false` without a + /// folder, and when `turn` isn't held). + pub(crate) fn unstore_plugin(&self, turn: &StoreTurn, id: &str) -> bool { + turn.held() && self.plugin_dir.as_deref().is_some_and(|dir| take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)))) } /// Keep the settings and cache of plug-in `id` in the attached plug-in store from now on; without /// one, nowhere (its storage calls then fail as unavailable). Settings and cache that a removal - /// kept are moved back first ([`plugin_storage`]). - pub(crate) fn attach_plugin_storage(&self, id: &str) { - vectorcraft_plugins::registry::set_storage(id, plugin_storage(self.plugin_dir.as_deref(), id)); + /// kept are moved back first, in `turn` ([`plugin_storage`]). + pub(crate) fn attach_plugin_storage(&self, turn: &StoreTurn, id: &str) { + vectorcraft_plugins::registry::set_storage(id, plugin_storage(self.plugin_dir.as_deref(), id, turn)); } /// Does the attached plug-in store have a folder of settings and cache for plug-in `id`, or one @@ -757,9 +859,13 @@ impl Session { /// that rename fails too, the error also names both paths and the reason, and the next attach or /// removal for the id moves the folder back. Once `forget` returns `true`, the folder and the /// kept settings are deleted ([`delete_aside`]); what that deletion leaves stays, with a log - /// record, and a later call for the id tries to delete it again. - pub(crate) fn remove_plugin_storage(&self, id: &str, forget: impl FnOnce() -> bool) -> Result<(), StorageError> { + /// record, and a later call for the id tries to delete it again. All of it happens in `turn`; + /// with a store and a turn that isn't held, nothing changes and this fails. + pub(crate) fn remove_plugin_storage(&self, turn: &StoreTurn, id: &str, forget: impl FnOnce() -> bool) -> Result<(), StorageError> { let not_forgotten = || StorageError::Io(GRANT_NOT_REMOVED.to_string()); + if self.plugin_dir.is_some() && !turn.held() { + return Err(StorageError::Io(STORE_NOT_LOCKED.to_string())); + } vectorcraft_plugins::registry::set_storage(id, None); let Some(dir) = &self.plugin_dir else { return if forget() { Ok(()) } else { Err(not_forgotten()) }; @@ -943,13 +1049,16 @@ fn restore_removed(path: &Path) -> std::io::Result<()> { /// Where plug-in `id` keeps its settings and cache in the plug-in store `dir`. Settings and cache /// that a removal kept are moved back first ([`restore_removed`]); when that fails, the failure is /// logged and there is no storage (its calls fail as unavailable), so that nothing the plug-in -/// writes takes their place. A later attach or load tries again. -fn plugin_storage(dir: Option<&Path>, id: &str) -> Option> { +/// writes takes their place. A later attach or load tries again. They are moved in `turn`; when it +/// isn't held, they stay where they are, as when moving them fails. +fn plugin_storage(dir: Option<&Path>, id: &str, turn: &StoreTurn) -> Option> { let dir = dir?; let data = plugin_data_dir(dir, id); if !is_link(&dir.join(PLUGIN_DATA_DIR)) { let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - if let Err(e) = restore_removed(&data) { + let kept_aside = || std::fs::symlink_metadata(&data).is_err() && aside_paths(&data).iter().any(|a| is_kept(a)); + let restored = if turn.held() || !kept_aside() { restore_removed(&data) } else { Err(std::io::Error::other(STORE_NOT_LOCKED)) }; + if let Err(e) = restored { log::warn!("can't move the settings and cache of {} back: {e}", data.display()); return None; } @@ -1088,6 +1197,12 @@ impl PluginStorage { Ok(()) } + /// A turn on the plug-in store ([`lock_store`]), taken before [`PLUGIN_DATA_LOCK`], so that a + /// window or process changing the same settings or cache waits for this change. + fn turn(&self) -> Result { + lock_store(&self.root).map_err(|e| StorageError::Io(format!("{STORE_NOT_LOCKED}: {e}"))) + } + fn kept(&self) -> MutexGuard<'_, Kept> { self.state.kept.lock().unwrap_or_else(|e| e.into_inner()) } @@ -1212,6 +1327,8 @@ impl PluginStorage { impl Storage for PluginStorage { fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError> { + // Reading may move a settings file that doesn't read aside. + let _turn = self.turn()?; let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; let mut kept = self.kept(); @@ -1219,10 +1336,15 @@ impl Storage for PluginStorage { } fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + let _turn = self.turn()?; let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; let mut kept = self.kept(); let settings = &mut self.read_settings(&mut kept.settings, meter)?.values; + #[cfg(test)] + if let Some(between) = AFTER_SETTINGS_READ.take() { + between(); + } if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) { return Err(StorageError::Full); } @@ -1234,6 +1356,7 @@ impl Storage for PluginStorage { } fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result { + let _turn = self.turn()?; let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; let mut kept = self.kept(); @@ -1258,6 +1381,8 @@ impl Storage for PluginStorage { } fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + // The cache's size and count are added up and the entry written in one turn. + let _turn = self.turn()?; let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; let path = self.entry_path(name); @@ -1281,6 +1406,7 @@ impl Storage for PluginStorage { } fn remove_cache_entry(&self, name: &str) -> Result { + let _turn = self.turn()?; let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; let path = self.entry_path(name); @@ -1296,6 +1422,7 @@ impl Storage for PluginStorage { } fn clear_cache(&self) -> Result { + let _turn = self.turn()?; let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; let dir = self.cache_dir(); diff --git a/crates/engine/src/tests/anchor_edit_work.rs b/crates/engine/src/tests/anchor_edit_work.rs index 7ad50a871d..1055156167 100644 --- a/crates/engine/src/tests/anchor_edit_work.rs +++ b/crates/engine/src/tests/anchor_edit_work.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Appearance, Document, Node, NodeId}; use vectorcraft_geom::path::measured_segments; -use vectorcraft_geom::{Anchor, PathData, Point, SubPath}; +use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2}; use crate::*; @@ -109,3 +109,29 @@ fn an_anchor_edit_that_changes_nothing_records_no_undo_step() { s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 5, "x": a.p.x + 1.0, "y": a.p.y})).unwrap(); assert_eq!(undo_steps(&s), 1); } + +/// `path.setHandle` to where the handle already is, on a smooth anchor whose other handle is not in +/// line with it, leaves both handles as they are and records no undo step. With `independent` it +/// makes the anchor a corner, which records one. +#[test] +fn a_handle_already_in_place_on_a_smooth_anchor_records_no_undo_step() { + let (mut s, id) = long_path(100); + let p = anchor(&s, id, 5).p; + let smooth = Anchor { p, h_in: p + Vec2::new(-3.0, 0.0), h_out: p + Vec2::new(0.0, 4.0), kind: AnchorKind::Smooth }; + let path = Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().path_data_mut().unwrap(); + path.subpaths[0].anchors[5] = smooth; + for which in ["out", "in"] { + let h = if which == "out" { smooth.h_out } else { smooth.h_in }; + s.execute("path.setHandle", &json!({"id": id.0, "anchor": 5, "which": which, "x": h.x, "y": h.y})).unwrap(); + assert_eq!(undo_steps(&s), 0, "path.setHandle on the {which} handle recorded an undo step"); + assert_eq!(anchor(&s, id, 5), smooth, "path.setHandle on the {which} handle changed the anchor"); + } + s.execute("path.setHandle", &json!({"id": id.0, "anchor": 5, "which": "out", "x": smooth.h_out.x, "y": smooth.h_out.y, "independent": true})) + .unwrap(); + assert_eq!(undo_steps(&s), 1); + assert_eq!(anchor(&s, id, 5).kind, AnchorKind::Corner); + // On a corner, the same independent request changes nothing. + s.execute("path.setHandle", &json!({"id": id.0, "anchor": 5, "which": "out", "x": smooth.h_out.x, "y": smooth.h_out.y, "independent": true})) + .unwrap(); + assert_eq!(undo_steps(&s), 1); +} diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index e961a59e81..bc4ce76c83 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -963,6 +963,69 @@ fn brush_freehand_paints_with_a_brush_in_one_step() { assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal")); } +/// `brush.freehand` refuses a `brush` that is not a string or names no brush in the document, +/// and a `style` other than "pencil" or "brush", before it draws: the document, the selection +/// and the undo history stay as they were. A `brush` left out or null draws the plain path. +#[test] +fn brush_freehand_refuses_a_brush_or_style_it_cannot_use() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + select(&mut s, &[a]); + let points = json!([[10, 10], [60, 40], [120, 10]]); + for (extra, words) in [ + (json!({"brush": "Nope"}), "no brush named `Nope`"), + (json!({"brush": ""}), "no brush named ``"), + (json!({"brush": 5}), "`brush` must be a string"), + (json!({"brush": {"name": "Charcoal"}}), "`brush` must be a string"), + (json!({"brush": true}), "`brush` must be a string"), + (json!({"brush": "Charcoal", "style": "marker"}), "`style` must be \"pencil\" or \"brush\", not \"marker\""), + (json!({"brush": "Charcoal", "style": 1}), "`style` must be \"pencil\" or \"brush\", not 1"), + ] { + let mut p = extra.clone(); + p["points"] = points.clone(); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute("brush.freehand", &p).expect_err(&format!("{extra} is refused")); + assert!(e.to_string().contains(words), "{extra}: {e}"); + assert!(*s.doc().unwrap().doc == *before, "{extra} leaves the document"); + assert_eq!(s.doc().unwrap().selection.objects, [a], "{extra} leaves the selection"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{extra} records no undo step"); + } + for extra in [json!({}), json!({"brush": null, "style": null})] { + let mut p = extra.clone(); + p["points"] = points.clone(); + let id = NodeId(s.execute("brush.freehand", &p).unwrap()["id"].as_u64().unwrap()); + assert_eq!(brush_of(&s, id), None, "{extra} draws the plain path"); + } + for style in ["pencil", "brush"] { + let r = s.execute("brush.freehand", &json!({"points": points, "style": style, "brush": "Charcoal"})).unwrap(); + assert!(r["id"].is_u64(), "{style}: {r}"); + } +} + +/// The Paintbrush skips a brush deleted after it was chosen: a deleted tool brush gives way to +/// the document's current brush, and with neither the stroke is drawn without a brush. +#[test] +fn paintbrush_tool_skips_a_deleted_brush() { + let mut s = session(); + s.execute("brush.setCurrent", &json!({"name": "Arrow"})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("paintbrush", v).unwrap(); + s.set_tool_option("brush", &json!("Dots")); + s.execute("brush.delete", &json!({"name": "Dots"})).unwrap(); + let stroke = |s: &mut Session, y: f64| { + for (k, x) in [(PointerKind::Down, 10.0), (PointerKind::Drag, 50.0), (PointerKind::Drag, 90.0), (PointerKind::Up, 130.0)] { + s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + s.doc().unwrap().selection.objects[0] + }; + let id = stroke(&mut s, 10.0); + assert_eq!(brush_of(&s, id).as_deref(), Some("Arrow")); + s.execute("brush.delete", &json!({"name": "Arrow"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.unknown.get("currentBrush"), None); + let id = stroke(&mut s, 110.0); + assert_eq!(brush_of(&s, id), None); +} + #[test] fn paintbrush_tool_uses_the_current_brush() { let mut s = session(); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 19ac53494c..e0d68ee949 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -4415,6 +4415,41 @@ fn a_trace_run_hands_its_trace_to_the_command() { assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}"); } +/// A trace made in one document applies only there: in another document with the same image, +/// object and settings, the run no longer applies, and both trace commands drop the trace handed +/// in and trace that document's own image, as one undo step. +#[test] +fn a_trace_run_applies_only_in_its_own_document() { + let p = json!({"params": {"ignoreWhite": true}}); + for command in ["imageTrace.make", "imageTrace.makeAndExpand"] { + let mut direct = session(); + image_doc(&mut direct); + let want = direct.execute(command, &p).unwrap(); + let mut s = session(); + let img = image_doc(&mut s); + let a = s.active_index().unwrap(); + let copy = (*s.doc().unwrap().doc).clone(); + let run = cmd::TraceRun::prepare(&s, command, &p).unwrap(); + let traced = run.run(&|_, _| true).unwrap(); + let b = s.add_document(copy, None); + assert_ne!(s.documents()[a].uid, s.documents()[b].uid); + assert!(!run.current(&s), "{command}: another document"); + assert!(s.set_active(a)); + assert!(run.current(&s), "{command}: its own document"); + assert!(s.set_active(b)); + traced.hand_in(&mut s); + let got = s.execute(run.command(), run.params()).unwrap(); + assert!(s.trace_hand_off.is_none(), "{command}: dropped"); + assert_eq!(got, want, "{command}"); + let g = NodeId(got["id"].as_u64().unwrap()); + assert_eq!(node(&s, g), node(&direct, g), "{command}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(node(&s, img).kind, NodeKind::Image(_)), "{command}: one undo step"); + assert!(s.set_active(a)); + assert!(matches!(node(&s, img).kind, NodeKind::Image(_)), "{command}: the first document is untouched"); + } +} + /// A plug-in run that calls Image Trace and then fails leaves a trace handed in for the command in /// place, as it leaves the document. #[test] diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index 493f569805..99d099f68d 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -184,6 +184,26 @@ fn rgb_cmyk_converts_each_gradient_stop() { } } +/// Each point of a freeform gradient converts on its own, as gradient stops do. +#[test] +fn rgb_cmyk_converts_each_freeform_point() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + let id = rect(&mut s, 10.0, 10.0, 50.0, 50.0, json!("#000000")); + let stops = json!([{"offset": 0, "color": Color::rgb(0.912_345, 0.1, 0.2)}, {"offset": 1, "color": Color::rgb(0.3, 0.4, 0.654_321)}]); + let points = json!([{"at": [20, 20], "color": Color::rgb(0.2, 0.876_543, 0.3)}, {"at": [50, 50], "color": Color::rgb(0.6, 0.111_111, 0.9)}]); + let gradient = json!({"kind": "freeform", "stops": stops, "freeform": {"points": points}}); + s.execute("paint.setFill", &json!({"ids": [id.0], "gradient": gradient})).unwrap(); + let points = |s: &Session| match fill_of(s, id) { + Paint::Gradient(g) => g.freeform.map(|f| f.points.iter().map(|p| p.color).collect::>()).unwrap_or_default(), + p => panic!("not a gradient: {p:?}"), + }; + let old = points(&s); + assert!(!old.is_empty(), "the freeform fill has points"); + s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap(); + assert_eq!(points(&s), old.into_iter().map(to_cmyk).collect::>()); +} + /// A pattern's tile art converts, and the art filled with the pattern keeps it. #[test] fn rgb_cmyk_converts_a_pattern_tile() { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 8936f8bc6f..d47ca0ccaf 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1651,6 +1651,97 @@ fn smoothing_a_whole_closed_path_smooths_a_bump_at_its_start() { assert!(start <= far + 0.5, "the bump at the start stays {start} pt high, the far one {far} pt"); } +/// Checks that the closed path `p` starts at its point nearest `old`, within `reach` of it: its first +/// anchor is no farther from `old` than any other point along the path, and its outline starts there. +fn assert_starts_nearest(p: &PathData, old: Point, reach: f64) { + let sp = &p.subpaths[0]; + assert!(sp.closed, "the path stays closed"); + let first = sp.anchors[0].p; + let nearest = smooth_test_samples(p).iter().map(|q| q.distance(old)).fold(f64::INFINITY, f64::min); + let d = first.distance(old); + assert!(d <= nearest + 1e-6 && d <= reach, "the path starts {d} pt from its old start, its nearest point is {nearest} pt away: {sp:?}"); + let mut bp = kurbo::BezPath::new(); + sp.to_bezpath_into(&mut bp); + assert_eq!(bp.elements().first(), Some(&kurbo::PathEl::MoveTo(first))); +} + +/// A drag over a closed path's first anchor and the anchors either side of it smooths the bump there, +/// and the path starts at the point of the smoothed path nearest the old first anchor. The anchors out +/// of reach keep their places, and undo brings the path back. +#[test] +fn smoothing_over_a_closed_paths_first_anchor_keeps_its_start() { + let mut s = session(); + let (c, r) = (Point::new(300.0, 300.0), 100.0); + let at = |deg: f64, rr: f64| c + rr * kurbo::Vec2::from_angle(deg.to_radians()); + let anchors: Vec<_> = (0..12) + .map(|i| { + let q = at(30.0 * f64::from(i), if i == 0 { r + 8.0 } else { r }); + json!({"x": q.x, "y": q.y}) + }) + .collect(); + let id = smooth_test_path(&mut s, &anchors, true); + let before = path(&s, id); + let old = before.subpaths[0].anchors[0].p; + // Along the circle from -40° to 40°, over anchors 11, 0 and 1. + let drag: Vec = (-20..=20).map(|k| at(2.0 * f64::from(k), r)).collect(); + smooth_pass(&mut s, &drag); + let after = path(&s, id); + assert_ne!(after, before, "the bump is smoothed"); + assert_starts_nearest(&after, old, 12.0); + let kept = &after.subpaths[0].anchors; + for a in &before.subpaths[0].anchors[2..=10] { + assert!(kept.iter().any(|k| k.p == a.p), "{:?} is out of reach and stays: {kept:?}", a.p); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id), before); +} + +/// A drag over the whole of a closed path with a spike that turns back on itself, which the refit +/// keeps as a corner, leaves the path starting at its point nearest the old first anchor. +#[test] +fn smoothing_a_whole_closed_path_with_a_spike_keeps_its_start() { + let mut s = session(); + let mut seed = 5u64; + let (c, r) = (Point::new(300.0, 300.0), 80.0); + let at = |a: f64, rr: f64| c + rr * kurbo::Vec2::from_angle(a); + use std::f64::consts::{PI, TAU}; + let mut pts: Vec = (0..64).map(|i| at(f64::from(i) / 64.0 * TAU, r + 2.5 * smooth_noise(&mut seed))).collect(); + // Out from the anchor at 180° to a tip 40 pt beyond the circle and back. + pts.splice(33..33, [at(PI, r + 40.0), at(PI + 0.002, r)]); + let anchors: Vec<_> = pts.iter().map(|q| json!({"x": q.x, "y": q.y})).collect(); + let id = smooth_test_path(&mut s, &anchors, true); + let before = path(&s, id); + let mut drag: Vec = (0..=128).map(|i| at(f64::from(i) / 128.0 * TAU, r)).collect(); + drag.splice(65..65, (0..=16).chain((0..=16).rev()).map(|k| at(PI, r + 2.5 * f64::from(k)))); + smooth_pass(&mut s, &drag); + let after = path(&s, id); + assert_ne!(after, before, "the path is smoothed"); + assert_starts_nearest(&after, pts[0], 12.0); +} + +/// A drag that reaches a closed path but not its first anchor leaves that anchor first, where it was. +#[test] +fn smoothing_a_closed_path_away_from_its_first_anchor_keeps_it_first() { + let mut s = session(); + let (c, r) = (Point::new(300.0, 300.0), 100.0); + let at = |deg: f64, rr: f64| c + rr * kurbo::Vec2::from_angle(deg.to_radians()); + let anchors: Vec<_> = (0..12) + .map(|i| { + let q = at(30.0 * f64::from(i), if i == 6 { r + 8.0 } else { r }); + json!({"x": q.x, "y": q.y}) + }) + .collect(); + let id = smooth_test_path(&mut s, &anchors, true); + let before = path(&s, id); + // Along the circle from 140° to 220°, over anchors 5, 6 and 7. + let drag: Vec = (70..=110).map(|k| at(2.0 * f64::from(k), r)).collect(); + smooth_pass(&mut s, &drag); + let after = path(&s, id); + assert_ne!(after, before, "the bump is smoothed"); + assert!(after.subpaths[0].closed); + assert_eq!(after.subpaths[0].anchors[0], before.subpaths[0].anchors[0]); +} + /// A straight line along y = 200 from x = 100 to 400, one anchor every 5 pt, whose second and /// second-to-last anchors are 1.5 pt off the line: its first and last segments leave and arrive at /// 16.7° to it. diff --git a/crates/engine/src/tests/inline.rs b/crates/engine/src/tests/inline.rs index f7c72956ad..71caf14bf8 100644 --- a/crates/engine/src/tests/inline.rs +++ b/crates/engine/src/tests/inline.rs @@ -1,8 +1,12 @@ //! Inline graphics in text: `text.insertInline`, editing over them, undo, following their symbol. +use std::sync::Arc; + use serde_json::json; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::text::INLINE_CHAR; use vectorcraft_doc::{Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Affine, Rect}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; use crate::*; @@ -325,6 +329,143 @@ fn a_symbol_that_shows_itself_inline_is_an_empty_slot() { assert_eq!(d.inline_art_bounds("Loop"), None); } +/// A rectangle `r` filled black, without a stroke. +fn filled_rect(d: &mut vectorcraft_doc::Document, r: Rect) -> Arc { + let look = vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); + Arc::new(Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), look)) +} + +/// An instance of symbol `name` placed by `xf`. +fn symbol_instance(d: &mut vectorcraft_doc::Document, name: &str, xf: Affine) -> Arc { + Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: name.into(), xf })) +} + +/// Gives symbol `name` the art `art` in a group, adding the symbol when there is none of that name. +fn set_symbol_art(d: &mut vectorcraft_doc::Document, name: &str, art: Vec>) { + let art = Arc::new(Node::group(d.alloc_id(), art)); + match d.symbols.iter_mut().find(|x| x.name == name) { + Some(x) => x.art = art, + None => d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art }), + } +} + +/// An inline graphic of a symbol whose art holds an instance of another symbol is sized by that +/// symbol's art, and follows it when it changes, through undo, redo and a saved copy. +#[test] +fn an_inline_graphic_follows_the_art_of_a_symbol_nested_in_its_symbol() { + let mut s = session(); + s.edit("Symbols", |d, _| { + let far = filled_rect(d, Rect::new(100.0, 0.0, 160.0, 20.0)); + set_symbol_art(d, "Far", vec![far]); + let inner = symbol_instance(d, "Far", Affine::IDENTITY); + set_symbol_art(d, "Holder", vec![inner]); + Ok(()) + }) + .unwrap(); + let id = text(&mut s, "ab"); + let w0 = obj(&s, id).bounds().unwrap().width(); + s.execute("text.insertInline", &json!({"id": id.0, "at": 1, "symbol": "Holder", "scale": 1})).unwrap(); + let wide = Some(Rect::new(100.0, 0.0, 160.0, 20.0)); + let t = obj(&s, id); + assert_eq!(inline_bounds(&t), wide); + // 20 pt tall at 20 pt type and 3 times as wide: the text grows by 60 pt. + let w1 = t.bounds().unwrap().width(); + assert!((w1 - w0 - 60.0).abs() < 1.5, "{w0} → {w1}"); + // Far redefined as a 10 × 40 rectangle: the graphic is 20 pt tall and 5 pt wide. + s.edit("Redefine Far", |d, _| { + let tall = filled_rect(d, Rect::new(0.0, 0.0, 10.0, 40.0)); + set_symbol_art(d, "Far", vec![tall]); + Ok(()) + }) + .unwrap(); + let tall = Some(Rect::new(0.0, 0.0, 10.0, 40.0)); + let t = obj(&s, id); + assert_eq!(inline_bounds(&t), tall); + assert_eq!(s.doc().unwrap().doc.symbol_art_bounds("Holder"), tall); + let w2 = t.bounds().unwrap().width(); + assert!((w2 - w0 - 5.0).abs() < 1.5, "{w0} → {w2}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(inline_bounds(&obj(&s, id)), wide); + assert!((obj(&s, id).bounds().unwrap().width() - w1).abs() < 1e-6); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(inline_bounds(&obj(&s, id)), tall); + // Opening a saved copy measures it the same way. + let doc = (*s.doc().unwrap().doc).clone(); + let mut back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + back.title = "copy".into(); + let mut s2 = Session::new(); + s2.add_document(back, None); + assert_eq!(inline_bounds(&obj(&s2, id)), tall); + assert!((obj(&s2, id).bounds().unwrap().width() - w2).abs() < 1e-6); +} + +/// Symbol C shows Holder inline, Holder holds an instance of B, and B shows A inline: with the +/// symbols listed C, Holder, B, A, a change to A reaches C through B's and Holder's new sizes. +#[test] +fn symbols_are_resolved_after_the_symbols_their_instances_show() { + let mut s = session(); + circle_symbol(&mut s, "A", 10.0); + let t = text(&mut s, "z"); + s.execute("text.insertInline", &json!({"id": t.0, "at": 0, "symbol": "A"})).unwrap(); + s.execute("symbol.new", &json!({"name": "B", "ids": [t.0]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + s.edit("Holder", |d, _| { + let inner = symbol_instance(d, "B", Affine::IDENTITY); + set_symbol_art(d, "Holder", vec![inner]); + Ok(()) + }) + .unwrap(); + let t = text(&mut s, "z"); + s.execute("text.insertInline", &json!({"id": t.0, "at": 0, "symbol": "Holder"})).unwrap(); + s.execute("symbol.new", &json!({"name": "C", "ids": [t.0]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + s.edit("Reverse the symbols", |d, _| { + d.symbols.reverse(); + Ok(()) + }) + .unwrap(); + let h0 = s.doc().unwrap().doc.symbol_art_bounds("Holder"); + redefine_as_wide_ellipse(&mut s, "A"); + let d = &s.doc().unwrap().doc; + assert_ne!(d.symbol_art_bounds("Holder"), h0); + assert_eq!(inline_bounds(&art_text(&s, "C")), d.symbol_art_bounds("Holder")); +} + +/// Symbol A holds an instance of B, and B's type shows A inline: the size of each would depend on +/// itself, so both are empty slots, and stay so through later edits. +#[test] +fn a_symbol_that_shows_itself_through_an_instance_is_an_empty_slot() { + let mut s = session(); + circle_symbol(&mut s, "A", 10.0); + let t = text(&mut s, "ab"); + s.execute("text.insertInline", &json!({"id": t.0, "at": 1, "symbol": "A"})).unwrap(); + s.execute("symbol.new", &json!({"name": "B", "ids": [t.0]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + s.edit("A holds B", |d, _| { + let inner = symbol_instance(d, "B", Affine::IDENTITY); + set_symbol_art(d, "A", vec![inner]); + Ok(()) + }) + .unwrap(); + let line = text(&mut s, "x"); + s.execute("text.insertInline", &json!({"id": line.0, "at": 0, "symbol": "A"})).unwrap(); + for _ in 0..2 { + for name in ["A", "B"] { + assert_eq!(s.doc().unwrap().doc.inline_art_bounds(name), None, "{name}"); + } + assert_eq!(inline_bounds(&art_text(&s, "B")), None); + assert_eq!(inline_bounds(&obj(&s, line)), None); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + } + let _ = s.execute("document.serialize", &json!({"format": "png"})).unwrap(); + // Redefined without the instance, A is on no cycle. + redefine_as_wide_ellipse(&mut s, "A"); + let d = &s.doc().unwrap().doc; + assert!(d.inline_art_bounds("A").is_some()); + assert_eq!(inline_bounds(&art_text(&s, "B")), d.symbol_art_bounds("A")); + assert_eq!(inline_bounds(&obj(&s, line)), d.symbol_art_bounds("A")); +} + /// A list paragraph that starts with an inline graphic draws its bullet before the graphic, and /// live SVG text writes the bullet. #[test] diff --git a/crates/engine/src/tests/library.rs b/crates/engine/src/tests/library.rs index c2dccc7bb5..a75bc532bf 100644 --- a/crates/engine/src/tests/library.rs +++ b/crates/engine/src/tests/library.rs @@ -733,6 +733,38 @@ fn a_library_character_style_linked_to_a_missing_swatch_is_added_unlinked() { assert_eq!((r["style"].as_str(), r["existing"].as_bool()), (Some("Accent"), Some(true))); } +/// A gradient stop linked to a spot swatch with process plates, as a library file can hold one. +fn plated_stops() -> Value { + let ink = vectorcraft_color::Color::cmyk(0.0, 0.5, 0.0, 0.0); + json!([ + {"offset": 0, "color": vectorcraft_color::Color::WHITE}, + {"offset": 1, "color": ink, "swatch": "Brand", "tint": 0.5, "plates": [0.3, 0, 0, 0]} + ]) +} + +/// Library gradients and the gradient stops of library character styles lose their links to +/// swatches the document does not have together with the process plates those links print with. +#[test] +fn library_stops_unlinked_from_their_swatch_keep_no_process_plates() { + let data = json!({ + "name": "Plated", + "gradients": [{"name": "Glow", "gradient": {"gradient": {"kind": "Linear", "stops": plated_stops()}, "angle": 0}}], + "charStyles": [{"name": "Accent", "attrs": {"fill": {"type": "gradient", "gradient": {"kind": "Linear", "stops": plated_stops()}}}}] + }); + let mut s = session(); + let lib = run(&mut s, "library.import", json!({"data": data.to_string()}))["id"].as_str().unwrap().to_string(); + let a = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [a]})); + run(&mut s, "library.use", json!({"library": lib, "kind": "fillGradient", "item": "Glow"})); + let st = gradient(&paint_of(&s, &a, false)).gradient.stops[1].clone(); + assert_eq!((st.swatch.as_deref(), st.tint, st.plates), (None, 1.0, None), "{st:?}"); + run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Accent"})); + let style = s.doc().unwrap().doc.char_styles.iter().find(|c| c.name == "Accent").map(|c| c.attrs["fill"].clone()).unwrap(); + let fill = serde_json::from_value::(style).unwrap(); + let st = gradient(&fill).gradient.stops[1].clone(); + assert_eq!((st.swatch.as_deref(), st.tint, st.plates), (None, 1.0, None), "{st:?}"); +} + /// A library style is compared with the document's in the form the document stores it in, so a /// style whose file writes a size as a whole number is reused, by Add to Character Styles and by a /// click alike. diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index 9ed3db2724..62955e6065 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -213,6 +213,24 @@ fn spot_stops_with_process_plates_export_both_in_device_n_shadings() { assert_eq!(gradient(&s, id).gradient.stops[1].plates, None); } +#[test] +fn deleting_a_spot_swatch_takes_the_process_plates_of_the_stops_it_unlinks() { + let (mut s, id) = setup(); + let plated = json!({"stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "swatch": "Ink", "tint": 50, "plates": [0.3, 0, 0, 0]}]}); + run(&mut s, "paint.editGradient", plated.clone()); + run(&mut s, "swatch.new", json!({"name": "Glow", "gradient": plated})); + let linked = gradient(&s, id).gradient.stops[1].clone(); + assert_eq!((linked.swatch.as_deref(), linked.plates), (Some("Ink"), Some([0.3, 0.0, 0.0, 0.0]))); + run(&mut s, "swatch.delete", json!({"name": "Ink"})); + // The art's stop and the gradient swatch's stop keep the color they show, unlinked and without plates. + let Paint::Gradient(sw) = doc(&s).swatch("Glow").unwrap().paint.clone() else { panic!("a gradient swatch") }; + for st in [&gradient(&s, id).gradient.stops[1], &sw.gradient.stops[1]] { + assert_eq!((st.color, st.swatch.as_deref(), st.tint, st.plates), (linked.color, None, 1.0, None), "{st:?}"); + } + run(&mut s, "edit.undo", json!({})); + assert_eq!(gradient(&s, id).gradient.stops[1], linked, "undo brings back the link and the plates"); +} + #[test] fn radial_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() { let (mut s, _) = setup(); diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index e683b156e1..62d3e36bad 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -768,6 +768,223 @@ fn nested_objects_that_repeat_ids_open_with_ids_of_their_own() { assert!(image(&s, layer_image).link.is_some(), "the image in the layers stays linked"); } +/// The files of the linked images that the stored brushes of [`brush_links`] are made of: a scatter +/// brush's, an art brush's and a pattern brush's side tile. +const BRUSHED: [(&str, &str, &str); 3] = [("scatter.png", "scatter", "Specks"), ("art.png", "art", "Band"), ("side.png", "pattern", "Frame")]; + +/// A document whose stored brushes are made of [`BRUSHED`]'s linked images in `dir`. The art +/// brush's image is still in the layers too, so its brush art repeats that object's id; the other +/// two images were cleared from the layers. +fn brush_links(dir: &Folder) -> (Session, NodeId) { + for (i, (name, ..)) in (0u8..).zip(BRUSHED) { + write(&dir.file(name), &png(20, 10, [30 + 60 * i, 120, 90])); + } + let mut s = session(); + let mut kept = NodeId(0); + for (name, kind, brush) in BRUSHED { + let id = place(&mut s, &dir.file(name)); + s.execute("brush.new", &json!({"type": kind, "name": brush, "ids": [id.0]})).unwrap(); + if kind == "art" { + kept = id; + } else { + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + } + } + s.execute("select.set", &json!({"ids": []})).unwrap(); + (s, kept) +} + +/// The image in the art of stored brush `name`, as the document stores it. +fn brush_image(s: &Session, name: &str) -> ImageObject { + let b = vectorcraft_brush::find(&s.doc().unwrap().doc, name).unwrap_or_else(|| panic!("no brush {name}")); + let mut found = None; + b.art()[0].walk(&mut |n| { + if let (None, NodeKind::Image(im)) = (&found, &n.kind) { + found = Some((**im).clone()); + } + }); + found.unwrap_or_else(|| panic!("no image in brush {name}")) +} + +/// The id of the image in the art of stored brush `name`, as the document stores it. +fn brush_image_id(s: &Session, name: &str) -> u64 { + let b = vectorcraft_brush::find(&s.doc().unwrap().doc, name).unwrap(); + let mut found = None; + b.art()[0].walk(&mut |n| { + if found.is_none() && matches!(n.kind, NodeKind::Image(_)) { + found = Some(n.id.0); + } + }); + found.unwrap() +} + +/// Images in the art of stored brushes are listed after the others, one row per image in each brush tile, +/// each with an id no other object has (the layer tree keeps its own), the brush named as what holds it; Check +/// Links and the missing filter count them, and the ids stay the same from one list to the next. +#[test] +fn links_in_stored_brush_art_are_listed_and_checked_with_ids() { + let dir = Folder::new("brush-ids"); + let (mut s, kept) = brush_links(&dir); + let list = s.execute("links.list", &json!({})).unwrap(); + let rows = list["links"].as_array().unwrap(); + let owners: Vec<(&str, &Value)> = rows.iter().map(|r| (r["name"].as_str().unwrap(), &r["owner"])).collect(); + let library = vectorcraft_brush::library(&s.doc().unwrap().doc); + let index = |name: &str| library.iter().position(|b| b.name == name).unwrap(); + assert_eq!( + owners, + [ + ("art.png", &Value::Null), + ("scatter.png", &json!({"kind": "brush", "name": "Specks", "index": index("Specks")})), + ("art.png", &json!({"kind": "brush", "name": "Band", "index": index("Band")})), + ("side.png", &json!({"kind": "brush", "name": "Frame", "index": index("Frame"), "tile": "side"})), + ], + "{list}" + ); + assert_eq!(rows[0]["id"], json!(kept.0), "the layer tree keeps its ids: {list}"); + let ids: std::collections::BTreeSet = rows.iter().map(|r| r["id"].as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 4, "{list}"); + assert!(!ids.contains(&0), "{list}"); + assert!(rows.iter().all(|r| r["status"] == "ok" && r["linked"] == true), "{list}"); + assert_eq!(s.execute("links.list", &json!({})).unwrap(), list, "the same ids each time"); + let other = super::rect(&mut s, 0.0, 0.0, 5.0, 5.0); + s.execute("edit.clear", &json!({"ids": [other.0]})).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap(), list, "the same ids after other objects are made"); + let check = s.execute("links.check", &json!({})).unwrap(); + assert_eq!(check["links"].as_array().unwrap().len(), 3, "{check}"); + std::fs::remove_file(dir.file("side.png")).unwrap(); + let check = s.execute("links.check", &json!({})).unwrap(); + assert_eq!(check["missing"].as_u64(), Some(1), "{check}"); + let missing = s.execute("links.list", &json!({"show": "missing"})).unwrap(); + assert_eq!(missing["links"][0]["id"], rows[3]["id"], "{missing}"); +} + +/// Every links command takes the id of an image in a stored brush's art and acts on that brush's art, +/// never on the image in the layers whose id the art repeated: Link Info, Go To (which clears the +/// selection and names the brush), Placement Options, Relink, Update and Embed. The ids each change +/// gives the art are kept in the library, so the next list names the same rows. +#[test] +fn links_commands_act_on_images_in_stored_brush_art_by_id() { + let dir = Folder::new("brush-act"); + let (mut s, kept) = brush_links(&dir); + let band = row_id_owned(&mut s, "art.png", "brush"); + assert_ne!(band, kept.0); + let info = s.execute("links.info", &json!({"id": band})).unwrap(); + assert_eq!((info["fileName"].as_str(), info["status"].as_str(), info["id"].as_u64()), (Some("art.png"), Some("ok"), Some(band)), "{info}"); + s.execute("select.set", &json!({"ids": [kept.0]})).unwrap(); + let go = s.execute("links.goTo", &json!({"id": band})).unwrap(); + assert_eq!( + (go["onCanvas"].clone(), go["owner"]["kind"].clone(), go["owner"]["name"].clone()), + (json!(false), json!("brush"), json!("Band")), + "{go}" + ); + assert!(s.doc().unwrap().selection.objects.is_empty(), "nothing on the canvas is selected"); + s.execute("links.placementOptions", &json!({"ids": [band], "preserve": "fit"})).unwrap(); + assert_eq!(s.execute("links.placementOptions", &json!({"ids": [band]})).unwrap()["placement"]["preserve"], "fit"); + assert_eq!(brush_image(&s, "Band").placement.preserve, vectorcraft_doc::links::Preserve::Fit); + assert_eq!(brush_image_id(&s, "Band"), band, "the brush keeps the id it is listed by"); + assert_ne!(image(&s, kept).placement.preserve, vectorcraft_doc::links::Preserve::Fit, "the image in the layers is left as it was"); + // Relink: the brush's image shows the new file; the image in the layers keeps its file. + let other = dir.file("other.png"); + write(&other, &png(40, 20, BLUE)); + let r = s.execute("links.relink", &json!({"ids": [band], "path": other})).unwrap(); + assert_eq!(r["relinked"], json!([band]), "{r}"); + assert_eq!(brush_image(&s, "Band").link.unwrap().path, other); + assert_eq!(image(&s, kept).link.unwrap().path, dir.file("art.png")); + assert_eq!(row_id_owned(&mut s, "other.png", "brush"), band, "{}", s.execute("links.list", &json!({})).unwrap()); + // Update: a changed file is read again for the scatter brush's image. + let dots = row_id_owned(&mut s, "scatter.png", "brush"); + let changed = png(30, 30, RED); + write(&dir.file("scatter.png"), &changed); + let r = s.execute("links.update", &json!({"ids": [dots]})).unwrap(); + assert_eq!(r["updated"], json!([dots]), "{r}"); + assert_eq!(brush_image(&s, "Specks").link.unwrap().size, Some(changed.len() as u64)); + // Embed: the pattern brush's side tile keeps its pixels and no longer links. + let frame = row_id_owned(&mut s, "side.png", "brush"); + let r = s.execute("links.embed", &json!({"ids": [frame]})).unwrap(); + assert_eq!(r["embedded"], json!([frame]), "{r}"); + assert!(brush_image(&s, "Frame").link.is_none()); + let embedded = s.execute("links.list", &json!({"show": "embedded"})).unwrap(); + assert_eq!(embedded["links"][0]["id"], json!(frame), "{embedded}"); + // Relink All Instances takes the image in the layers and the brush's image of the same file. + let r = s.execute("links.relink", &json!({"ids": [kept.0], "path": dir.file("scatter.png"), "scope": "all"})).unwrap(); + assert_eq!(r["relinked"], json!([kept.0]), "{r}"); + let r = s.execute("links.relink", &json!({"ids": [dots], "path": other, "scope": "all"})).unwrap(); + let mut relinked: Vec = r["relinked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + relinked.sort(); + let mut both = vec![kept.0, dots]; + both.sort(); + assert_eq!(relinked, both, "{r}"); + assert_eq!(image(&s, kept).link.unwrap().path, other); + // One undo step puts the brushes back as they were. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(brush_image(&s, "Specks").link.unwrap().path, dir.file("scatter.png")); +} + +/// The id of the `links.list` row of file `name` whose owner is of `kind`. +fn row_id_owned(s: &mut Session, name: &str, kind: &str) -> u64 { + let list = s.execute("links.list", &json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == name && r["owner"]["kind"] == kind); + row.unwrap_or_else(|| panic!("{name} in {kind}: {list}"))["id"].as_u64().unwrap() +} + +/// A document saved with links in its stored brushes opens with them reported by the ids the list +/// gives them, and a library entry that does not read as a brush is kept as it was. +#[test] +fn links_in_stored_brush_art_open_by_id_and_unreadable_entries_stay() { + let dir = Folder::new("brush-open"); + let (mut s, _) = brush_links(&dir); + let odd = json!({"name": "Odd", "type": "unknown", "art": {"id": 3}}); + s.edit("Odd brush", |d, _| { + if let Some(list) = d.unknown.get_mut(vectorcraft_brush::DOC_KEY).and_then(Value::as_array_mut) { + list.push(odd.clone()); + } + Ok(()) + }) + .unwrap(); + let path = dir.file("poster.vectorcraft"); + save(&mut s, &path); + std::fs::remove_file(dir.file("scatter.png")).unwrap(); + let r = open(&mut s, &path); + let dots = row_id_owned(&mut s, "scatter.png", "brush"); + assert_eq!(r["missingLinks"][0]["ids"], json!([dots]), "{r}"); + assert_eq!(brush_image_id(&s, "Specks"), dots, "the library keeps the ids opening gave"); + let band = row_id_owned(&mut s, "art.png", "brush"); + s.execute("links.embed", &json!({"ids": [band]})).unwrap(); + let doc = &s.doc().unwrap().doc; + let stored = doc.unknown[vectorcraft_brush::DOC_KEY].as_array().unwrap(); + assert_eq!(stored.last(), Some(&odd), "the unreadable entry is left as it was"); + assert!(doc.mounted_art.is_empty()); +} + +/// The Color Mask of an image in a stored brush's art is made again for the new pixels when Update +/// reads the image's changed file. +#[test] +fn updating_an_image_in_stored_brush_art_makes_its_color_mask_again() { + let dir = Folder::new("brush-mask"); + let path = dir.file("logo.png"); + write(&path, &png(12, 10, RED)); + let mut s = session(); + let id = place(&mut s, &path); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let hide_white = json!({"background": "colors", "colors": "#ffffff", "contiguous": false, "tolerance": 0, "softness": 0}); + s.execute("addon.image.colorMask", &hide_white).unwrap(); + s.execute("brush.new", &json!({"type": "scatter", "name": "Masked", "ids": [id.0]})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + write(&path, &png(14, 10, BLUE)); + let row = row_id_owned(&mut s, "logo.png", "brush"); + let r = s.execute("links.update", &json!({"ids": [row]})).unwrap(); + assert_eq!(r["updated"], json!([row]), "{r}"); + let doc = &s.doc().unwrap().doc; + let brush = vectorcraft_brush::find(doc, "Masked").unwrap(); + let mut masks = vec![]; + brush.art()[0].walk(&mut |n| { + if let (NodeKind::Image(im), Some(m)) = (&n.kind, Document::color_mask_of(n)) { + masks.push((m.made_for(im), doc.images.contains_key(&m.key))); + } + }); + assert_eq!(masks, [(true, true)], "the mask is made for the new pixels"); +} + /// An image that Placement Options clip, alone in a group the user made, goes into a new clip group /// inside that group when its file grows, in the layers and in a symbol's art; the user's group /// stays a plain group. diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs index e61c4bb4a4..952693d7c1 100644 --- a/crates/engine/src/tests/package.rs +++ b/crates/engine/src/tests/package.rs @@ -90,6 +90,38 @@ fn two_links_and_a_font_are_collected_and_relinked() { assert!(s.doc().unwrap().doc.images.values().all(|b| !b.is_proxy()), "the copies' pixels"); } +/// A file linked only from a stored brush's art is copied, and the packaged document's brush +/// links to the copy. +#[test] +fn a_file_linked_from_a_stored_brush_is_collected_and_relinked() { + let dir = Folder::new("package-brush"); + let file = dir.file("art/leaf.png"); + write(&file, &png(40, 20, RED)); + let mut s = session(); + let id = place(&mut s, &file); + run(&mut s, "brush.new", json!({"type": "scatter", "name": "Leaves Here", "ids": [id.0]})); + run(&mut s, "edit.clear", json!({"ids": [id.0]})); + save(&mut s, &dir.file("poster.vectorcraft")); + let out = dir.file("out"); + let r = run(&mut s, "file.package", json!({"folder": out, "copyFonts": false})); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + assert!(files.contains(&"Links/leaf.png"), "{r}"); + let root = std::path::Path::new(&out).join("poster Folder"); + std::fs::remove_dir_all(dir.0.join("art")).unwrap(); + let r = open(&mut s, &root.join("poster.vectorcraft").to_string_lossy()); + assert_eq!(r["missingLinks"], json!([]), "{r}"); + let brush = vectorcraft_brush::find(&s.doc().unwrap().doc, "Leaves Here").unwrap(); + let mut paths = vec![]; + brush.art()[0].walk(&mut |n| { + if let vectorcraft_doc::NodeKind::Image(im) = &n.kind { + paths.push(im.link.as_ref().map(|l| (l.path.clone(), l.relative.clone()))); + } + }); + let [Some((path, relative))] = paths.as_slice() else { panic!("{paths:?}") }; + assert!(std::path::Path::new(path).starts_with(root.join("Links")), "{path}"); + assert_eq!(relative.as_deref(), Some("Links/leaf.png")); +} + #[test] fn an_unsaved_document_cant_be_packaged() { let mut s = session(); diff --git a/crates/engine/src/tests/perspgrid.rs b/crates/engine/src/tests/perspgrid.rs index 07fce547da..dce149e98c 100644 --- a/crates/engine/src/tests/perspgrid.rs +++ b/crates/engine/src/tests/perspgrid.rs @@ -150,6 +150,42 @@ fn built_in_views_apply_by_name_or_type() { assert!(s.execute("perspective.grid.preset", &json!({})).is_err()); } +#[test] +fn reapplying_the_current_grid_preset_records_no_undo_step() { + let mut s = session(); + // On a document with no grid defined, a preset defines and shows one: an undo step. + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + assert_eq!(undo_len(&s), 1); + let g = grid(&s); + // The same preset again, by type or by name, leaves the grid as it is: no undo step. + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + run(&mut s, "perspective.grid.preset", json!({"name": "[2P-Normal View]"})); + assert_eq!((grid(&s), undo_len(&s)), (g.clone(), 1)); + // Another active plane, or a hidden grid, is set back without an undo step. + run(&mut s, "perspective.plane.set", json!({"plane": "ground"})); + run(&mut s, "perspective.grid.show", json!({"visible": false})); + assert_ne!(grid(&s), g); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + assert_eq!((grid(&s), undo_len(&s)), (g, 1)); + // Another preset changes the grid. + run(&mut s, "perspective.grid.preset", json!({"kind": 3})); + assert_eq!((grid(&s).kind, undo_len(&s)), (3, 2)); +} + +#[test] +fn define_grid_with_unchanged_fields_records_no_undo_step() { + let mut s = session(); + // On a document with no grid defined, `perspective.grid.set` stores the grid: an undo step, + // and the grid no longer follows the first artboard. + run(&mut s, "perspective.grid.set", json!({})); + assert_eq!((run(&mut s, "perspective.grid.get", json!({}))["defined"].clone(), undo_len(&s)), (json!(true), 1)); + // The stored grid's own values leave it as it is: no undo step. + let g = grid(&s); + run(&mut s, "perspective.grid.set", json!({})); + run(&mut s, "perspective.grid.set", json!({"cell": g.cell, "horizon": g.horizon})); + assert_eq!((grid(&s), undo_len(&s)), (g, 1)); +} + #[test] fn presets_save_rename_delete_and_protect_the_built_in_ones() { let mut s = session(); diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 2420ef265c..c639fc9232 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1042,6 +1042,57 @@ fn saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns() { assert_eq!(hashes, [hash.clone(), hash], "the symbol's, then the pattern's"); } +/// Saving a document reads it again for its placed copy in the art of a stored brush of another +/// open document, the only place that document shows it. +#[test] +fn saving_a_document_refreshes_its_placed_copy_in_a_stored_brush() { + let dir = Folder::new("placed-refresh-brush"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + s.execute("brush.new", &json!({"type": "scatter", "name": "Logos", "ids": [id.0]})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let parent = s.active_index().unwrap(); + s.execute("document.open", &json!({"path": src})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#1414e6"})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + let hash = Some(vectorcraft_doc::links::hash_bytes(&std::fs::read(&src).unwrap())); + let doc = &s.documents()[parent]; + assert_eq!(doc.history.undo.last().unwrap().label, "Update Links"); + let brush = vectorcraft_brush::find(&doc.doc, "Logos").unwrap(); + let mut hashes = vec![]; + brush.art()[0].walk(&mut |n| { + if let NodeKind::PlacedDocument(p) = &n.kind { + hashes.push(p.link.hash.clone()); + } + }); + assert_eq!(hashes, [hash]); +} + +/// A placed document kept only in a stored brush's art, whose file is gone and of which the +/// document holds only the preview, is output as its preview with the warning that says so. +#[test] +fn a_placed_document_in_a_stored_brush_without_its_file_is_output_as_its_preview() { + let dir = Folder::new("placed-brush-preview"); + let src = dir.file("logo.vectorcraft"); + // Content of its own: files read are cached by content, and this one is removed. + source(&src, 100.0, 50.0, RED, 3); + let mut s = session(); + let id = place(&mut s, &src); + s.execute("brush.new", &json!({"type": "scatter", "name": "Logos", "ids": [id.0]})).unwrap(); + // The copy in the layers has the save keep only the preview. + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + std::fs::remove_file(&src).unwrap(); + let r = open(&mut s, &doc_path); + assert_eq!(r["missingLinks"].as_array().map(Vec::len), Some(1), "{r}"); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + assert!(svg["warnings"].to_string().contains("1 placed document output as its preview"), "{svg}"); +} + #[test] fn link_info_gives_the_artboards_size_not_pixels() { let dir = Folder::new("placed-info"); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 499f2d15c6..a294cc9036 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1921,6 +1921,12 @@ fn disabled_plugins_name_files_outside_the_read_root_by_their_names() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// The files and folders in the plug-in store `dir`, without the file windows lock while they +/// change it. +fn store_files(dir: &std::path::Path) -> usize { + std::fs::read_dir(dir).unwrap().filter(|e| e.as_ref().is_ok_and(|e| e.file_name() != crate::store::DISABLED_LOCK)).count() +} + /// #2541: a plug-in installed in a session with a plug-in store is installed again in the next /// session that attaches the store, and Remove Plug-in takes it out of the store. #[test] @@ -1931,14 +1937,14 @@ fn installed_plugins_are_kept_in_the_store() { let mut s = session(); s.attach_plugin_store(folder.0.clone()); install_wat(&mut s, &src); - assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "one file in the store"); + assert_eq!(store_files(&folder.0), 1, "one file in the store"); vectorcraft_plugins::registry::remove(id); let mut next = session(); next.attach_plugin_store(folder.0.clone()); assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store"); run(&mut next, "plugin.remove", json!({"id": id})); - assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 0, "removed from the store"); + assert_eq!(store_files(&folder.0), 0, "removed from the store"); // A file that isn't a plug-in is skipped; the store still attaches. std::fs::write(folder.0.join("broken.wasm"), b"\0asm?").unwrap(); @@ -3274,6 +3280,198 @@ fn a_store_loaded_while_installing_loads_the_new_version() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// A grant of `doc.read` to plug-in `id`, the module with the SHA-256 `hash`. +fn doc_read_grant(id: &str, hash: &str) -> vectorcraft_plugins::permissions::Grant { + serde_json::from_value(json!({"id": id, "hash": hash, "permissions": ["doc.read"]})).unwrap() +} + +/// A session that uses the plug-in store `dir` without loading it, as another process using the +/// same folder does: it doesn't wait for the installs and removals of this process's sessions. +fn other_process(dir: std::path::PathBuf) -> Session { + let mut s = Session::new(); + s.plugin_dir = Some(dir); + s +} + +/// Waits on this thread for a turn on a plug-in store, telling `waiting` before it waits. +fn tell_when_waiting(waiting: std::sync::mpsc::Sender<&'static str>) { + crate::store::WHILE_STORE_LOCKED.set(Some(Box::new(move || { + let _ = waiting.send("waiting"); + }))); +} + +/// The ids `grants.json` in `dir` holds grants for, and the hash of each. +fn stored_grant_hashes(dir: &std::path::Path) -> std::collections::BTreeMap { + let entries: Vec = serde_json::from_slice(&std::fs::read(dir.join("grants.json")).unwrap()).unwrap(); + entries.iter().map(|e| (e["id"].as_str().unwrap().to_string(), e["hash"].as_str().unwrap().to_string())).collect() +} + +/// An install into a `Plug-ins` folder that another process also installs the same id into keeps a +/// module with its own grant: the other process's install waits for this one's turn on the folder, +/// between this install's module and its grant, and then writes both of its own. +#[test] +fn installs_from_two_processes_into_one_store_keep_each_module_with_its_grant() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-installs-take-turns"); + let id = "test.engine.permissions.installsturns"; + let ours = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let theirs = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "documentData": null})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + + let (said, heard) = std::sync::mpsc::channel::<&'static str>(); + let (handed, other) = std::sync::mpsc::channel(); + let (dir, module) = (folder.0.clone(), theirs.clone()); + crate::cmd::plugin::BEFORE_GRANT_STORED.set(Some(Box::new(move || { + let thread = std::thread::spawn(move || { + tell_when_waiting(said.clone()); + let other = other_process(dir); + let turn = other.plugin_store_turn().unwrap(); + let stored = crate::cmd::plugin::keep_installed(&other, &turn, id, &module, Some(doc_read_grant(id, &sha256_hex(&module)))); + let _ = said.send(if stored { "stored" } else { "not stored" }); + }); + // The other install has written the store, or waits for this one. + let first = heard.recv().unwrap_or("gone"); + let _ = handed.send((first, thread)); + }))); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&ours), "grant": ["doc.read"]})); + let (first, thread) = other.recv().unwrap(); + thread.join().unwrap(); + + let module = std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(); + assert_eq!(stored_grant_hashes(&folder.0).get(id), Some(&sha256_hex(&module)), "the stored grant covers the stored module"); + assert_eq!(module, theirs, "the install that waited wrote last"); + assert_eq!(first, "waiting", "the other install waits for this one"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// Grants written for two plug-ins into one `Plug-ins` folder by two processes are both kept: the +/// second write reads `grants.json` only once the first has written it. +#[test] +fn grants_written_by_two_processes_into_one_store_keep_both() { + let folder = crate::tests_links::Folder::new("plugin-store-grants-take-turns"); + let (ours, theirs) = ("test.engine.permissions.grantturnsa", "test.engine.permissions.grantturnsb"); + let hash = vectorcraft_plugins::permissions::sha256_hex(b"module"); + let s = other_process(folder.0.clone()); + + let (said, heard) = std::sync::mpsc::channel::<&'static str>(); + let (handed, other) = std::sync::mpsc::channel(); + let (dir, other_hash) = (folder.0.clone(), hash.clone()); + crate::store::AFTER_GRANTS_READ.set(Some(Box::new(move || { + let thread = std::thread::spawn(move || { + tell_when_waiting(said.clone()); + let written = other_process(dir).store_grant(theirs, Some(doc_read_grant(theirs, &other_hash))); + let _ = said.send(if written { "written" } else { "not written" }); + }); + // The other write has written the file, or waits for this one. + let first = heard.recv().unwrap_or("gone"); + let _ = handed.send((first, thread)); + }))); + assert!(s.store_grant(ours, Some(doc_read_grant(ours, &hash))), "the grant is written"); + let (first, thread) = other.recv().unwrap(); + thread.join().unwrap(); + + let kept = stored_grant_hashes(&folder.0); + assert_eq!(kept.keys().map(String::as_str).collect::>(), [ours, theirs], "both grants are kept"); + assert_eq!(first, "waiting", "the other write waits for this one"); +} + +/// A setting a plug-in changes in one process and another setting it changes in a second process +/// using the same `Plug-ins` folder are both kept: the second process waits for the first's turn on +/// the folder and reads what it wrote. The second process is this test run again with +/// `VECTORCRAFT_TEST_PLUGIN_SETTINGS_STORE` naming the folder; it prints when it waits and when it +/// is done. +#[test] +fn settings_changed_by_two_processes_keep_both() { + use std::io::{BufRead, Write}; + use vectorcraft_plugins::storage::Meter; + const CHILD: &str = "VECTORCRAFT_TEST_PLUGIN_SETTINGS_STORE"; + const NAME: &str = "tests::plugins::settings_changed_by_two_processes_keep_both"; + let id = "test.engine.storagetwoprocesses"; + // Nothing a removal kept is moved back, so the storage is made without a turn on the folder. + let attached = |s: &Session| { + s.attach_plugin_storage(&crate::store::StoreTurn::unheld(), id); + vectorcraft_plugins::registry::storage(id).unwrap() + }; + if let Some(dir) = std::env::var_os(CHILD) { + let st = attached(&other_process(std::path::PathBuf::from(dir))); + crate::store::WHILE_STORE_LOCKED.set(Some(Box::new(|| { + println!("settings-child-waiting"); + let _ = std::io::stdout().flush(); + }))); + st.set_setting("theirs", b"2", &mut Meter::unlimited()).unwrap(); + println!("settings-child-done"); + let _ = std::io::stdout().flush(); + return; + } + let folder = crate::tests_links::Folder::new("plugin-settings-two-processes"); + let st = attached(&other_process(folder.0.clone())); + + let (handed, child) = std::sync::mpsc::channel(); + let dir = folder.0.clone(); + crate::store::AFTER_SETTINGS_READ.set(Some(Box::new(move || { + let mut process = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", NAME, "--nocapture", "--test-threads=1"]) + .env(CHILD, &dir) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let mut lines = std::io::BufReader::new(process.stdout.take().unwrap()).lines(); + // The other process has written the settings, or waits for this change. + let first = lines + .by_ref() + .map_while(std::io::Result::ok) + .find_map(|l| ["waiting", "done"].into_iter().find(|said| l.contains(&format!("settings-child-{said}")))); + let _ = handed.send((first, process, lines)); + }))); + st.set_setting("ours", b"1", &mut Meter::unlimited()).unwrap(); + let (first, mut process, lines) = child.recv().unwrap(); + let rest: Vec = lines.map_while(std::io::Result::ok).collect(); + assert!(process.wait().unwrap().success(), "the other process failed: {rest:?}"); + + let file: std::collections::BTreeMap = + serde_json::from_slice(&std::fs::read(crate::store::plugin_data_dir(&folder.0, id).join("settings.json")).unwrap()).unwrap(); + assert_eq!(file.keys().map(String::as_str).collect::>(), ["ours", "theirs"], "both settings are kept"); + assert_eq!(first, Some("waiting"), "the other process waits for this change"); + vectorcraft_plugins::registry::set_storage(id, None); +} + +/// When the `Plug-ins` folder can't be locked (here `disabled.lock` is a folder), `plugin.install` +/// and `plugin.remove` change nothing and say why: the plug-in installed with the id, its grant and +/// the stored module stay as they were. +#[test] +fn a_store_that_cant_be_locked_refuses_installs_and_removals() { + use vectorcraft_plugins::{permissions::sha256_hex, registry}; + let folder = crate::tests_links::Folder::new("plugin-store-not-locked"); + let id = "test.engine.permissions.notlocked"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let second = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "documentData": null})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + let lock = folder.0.join(crate::store::DISABLED_LOCK); + std::fs::remove_file(&lock).unwrap(); + std::fs::create_dir(&lock).unwrap(); + let grants = std::fs::read(folder.0.join("grants.json")).unwrap(); + + let update = json!({"dataBase64": vectorcraft_format::base64_encode(&second), "grant": ["doc.read"], "replace": true}); + let refused = s.execute("plugin.install", &update).unwrap_err().to_string(); + assert!(refused.contains(crate::store::STORE_NOT_LOCKED) && refused.contains("nothing was installed"), "{refused}"); + assert_eq!(registry::get(id).map(|p| p.hash().to_string()), Some(sha256_hex(&first)), "the installed plug-in stays"); + assert_eq!(registry::granted(id).map(|g| g.hash), Some(sha256_hex(&first)), "its grant stays"); + assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), first, "the store keeps its module"); + assert_eq!(std::fs::read(folder.0.join("grants.json")).unwrap(), grants, "and its grant"); + + let refused = s.execute("plugin.remove", &json!({"id": id})).unwrap_err().to_string(); + assert!(refused.contains(crate::store::STORE_NOT_LOCKED) && refused.contains("nothing was removed"), "{refused}"); + assert!(registry::get(id).is_some(), "the plug-in stays installed"); + assert!(std::path::Path::new(&s.stored_plugin_file(id).unwrap()).exists(), "and stored"); + + std::fs::remove_dir(&lock).unwrap(); + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(registry::get(id).is_none()); +} + /// Runs the test `name` of this module again in a process of its own → whether this is that /// process. The list of disabled plug-ins is kept for the whole process, and the other tests here /// read it again each time they attach a plug-in store. @@ -3740,7 +3938,7 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { let mut third = session(); third.attach_plugin_store(folder.0.clone()); assert!(vectorcraft_plugins::registry::get(id).is_none(), "nobody granted it"); - assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "the module stays in the store"); + assert_eq!(store_files(&folder.0), 1, "the module stays in the store"); // The Additional Plug-ins Folder skips it with a note until it is granted. let dir = crate::tests_links::Folder::new("plugin-folder-grants"); @@ -4678,7 +4876,7 @@ fn plugin_storage_is_capped_and_survives_a_damaged_settings_file() { // An id that differs only in case has storage of its own, which removing this one leaves. let upper_id = "test.engine.StorageCaps"; - s.attach_plugin_storage(upper_id); + s.attach_plugin_storage(&s.plugin_store_turn().unwrap(), upper_id); let upper = vectorcraft_plugins::registry::storage(upper_id).unwrap(); assert_eq!(upper.setting("k0", m).unwrap(), None); upper.set_setting("k0", b"upper", m).unwrap(); @@ -4690,13 +4888,13 @@ fn plugin_storage_is_capped_and_survives_a_damaged_settings_file() { // Removing the plug-in closes the storage a run still holds, even one from before the plug-in // was replaced, so nothing is written back. - s.attach_plugin_storage(id); + s.attach_plugin_storage(&s.plugin_store_turn().unwrap(), id); run(&mut s, "plugin.remove", json!({"id": id})); assert_eq!(st.set_setting("zoom", b"again", m), Err(StorageError::Unavailable)); assert_eq!(st.set_cache_entry("tile", b"again", m), Err(StorageError::Unavailable)); assert!(!dir.exists()); assert_eq!(upper.setting("k0", m).unwrap(), Some(b"upper".to_vec())); - s.remove_plugin_storage(upper_id, || true).unwrap(); + s.remove_plugin_storage(&s.plugin_store_turn().unwrap(), upper_id, || true).unwrap(); } /// Files larger than the storage reads are not read: a settings file larger than 1 MiB, although @@ -4874,12 +5072,12 @@ fn removing_a_plugin_drops_its_settings_from_memory() { std::fs::create_dir_all(file.parent().unwrap()).unwrap(); std::fs::write(&file, &bytes).unwrap(); set_modified(&file, written); - s.attach_plugin_storage(id); + s.attach_plugin_storage(&s.plugin_store_turn().unwrap(), id); let st = vectorcraft_plugins::registry::storage(id).unwrap(); let mut m = Meter::unlimited(); assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"hello".to_vec())); assert_eq!(m.used(), bytes.len() as u64, "read from the file"); - s.remove_plugin_storage(id, || true).unwrap(); + s.remove_plugin_storage(&s.plugin_store_turn().unwrap(), id, || true).unwrap(); } /// The cache's total stays in memory while the cache folder keeps its time; a write and a clear diff --git a/crates/engine/src/tests/swatches.rs b/crates/engine/src/tests/swatches.rs index 8abaec348c..e59e9a90b1 100644 --- a/crates/engine/src/tests/swatches.rs +++ b/crates/engine/src/tests/swatches.rs @@ -1099,13 +1099,18 @@ fn fill_and_stroke(s: &Session, id: NodeId) -> (Paint, Paint) { /// in the batch, and one drawn after it, the same fill and stroke. Returns the batch's rectangle's /// fill and stroke. fn batch_matches_steps(steps: &[(&str, Value)]) -> (Paint, Paint) { + batch_matches_steps_from(brand_defaults_session, steps) +} + +/// [`batch_matches_steps`] with both sessions made by `setup`. +fn batch_matches_steps_from(setup: impl Fn() -> Session, steps: &[(&str, Value)]) -> (Paint, Paint) { let shape = ("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); - let mut one_by_one = brand_defaults_session(); + let mut one_by_one = setup(); for (id, p) in steps { run(&mut one_by_one, id, p.clone()); } let single = rect(&mut one_by_one); - let mut batched = brand_defaults_session(); + let mut batched = setup(); let commands: Vec = steps.iter().chain([&shape]).map(|(id, p)| json!({"command": id, "params": p})).collect(); let r = run(&mut batched, "command.batch", json!({ "commands": commands })); let in_batch = NodeId(r["results"][steps.len()]["id"].as_u64().unwrap()); @@ -1133,6 +1138,43 @@ fn swatch_steps_in_a_batch_change_the_new_art_paints_as_commands_do() { assert_eq!(batch_matches_steps(&[("swatch.editGroup", json!({"group": "G", "colors": ["#ababab"]}))]), (gray.clone(), gray)); } +#[test] +fn spot_colors_options_in_a_batch_change_the_new_art_paints_as_the_command_does() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let lab = Color::Lab { l: 55.0, a: 60.0, b: 40.0 }; + let cms = vectorcraft_color::cms::active(); + let cmyk = cms.convert(&lab, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent); + assert!(matches!(cmyk, Color::Cmyk { .. })); + // The default fill and stroke for new art are linked to 40 % of the Lab spot swatch "Lab Ink". + let lab_ink_defaults = |use_lab: bool| { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Lab Ink", "color": {"l": 55, "a": 60, "b": 40}, "spot": true})); + run(&mut s, "swatch.spotOptions", json!({ "useLab": use_lab })); + run(&mut s, "select.set", json!({"ids": []})); + run(&mut s, "paint.setFill", json!({"swatch": "Lab Ink", "tint": 40})); + run(&mut s, "paint.setStroke", json!({"swatch": "Lab Ink", "tint": 40})); + s + }; + let ink = |color: Color| Paint::Solid { color: color.tinted(0.4), swatch: Some("Lab Ink".into()), tint: 0.4 }; + assert_eq!(lab_ink_defaults(true).paint.fill, ink(lab)); + let to_cmyk = [("swatch.spotOptions", json!({"useLab": false}))]; + assert_eq!(batch_matches_steps_from(|| lab_ink_defaults(true), &to_cmyk), (ink(cmyk), ink(cmyk))); + assert_eq!(lab_ink_defaults(false).paint.fill, ink(cmyk)); + let to_lab = [("swatch.spotOptions", json!({"useLab": true}))]; + assert_eq!(batch_matches_steps_from(|| lab_ink_defaults(false), &to_lab), (ink(lab), ink(lab))); +} + +#[test] +fn default_swatches_replace_in_a_batch_unlinks_the_new_art_paints_as_the_command_does() { + // Brand and Other are not default swatches, so replacing the swatches drops the links to them. + let blue = Paint::solid(Color::from_hex("#2a6fb0").unwrap()); + let replace = [("swatch.resetDefaults", json!({"replace": true}))]; + assert_eq!(batch_matches_steps(&replace), (blue.clone(), blue)); + // Without replace the swatches stay, and so do the links. + let link = linked("#2a6fb0", "Brand"); + assert_eq!(batch_matches_steps(&[("swatch.resetDefaults", json!({}))]), (link.clone(), link)); +} + #[test] fn undo_and_redo_of_a_swatch_edit_put_back_the_new_art_paints() { let edit = json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"}); diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 445ce38ea7..dbf32eba7c 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -636,6 +636,114 @@ fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradi assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into())); } +/// A radial gradient from red to blue, with `extra` keys. +fn radial_gradient(extra: Value) -> Value { + let mut g = json!({"kind": "radial", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + if let (Some(g), Some(extra)) = (g.as_object_mut(), extra.as_object()) { + g.extend(extra.clone()); + } + g +} + +/// The gradient swatch `name` keeps an aspect of 50%, holds no swatch link, and applies with that +/// aspect, fitted to the object it is applied to. +fn assert_swatch_applies_with_half_aspect(s: &mut Session, name: &str) { + let Paint::Gradient(sw) = &doc(s).swatch(name).unwrap().paint else { panic!("{name}: a gradient swatch") }; + assert!(sw.geom.is_some_and(|g| (g.aspect - 0.5).abs() < 1e-9), "{name} keeps the aspect: {:?}", sw.geom); + assert_eq!(sw.swatch, None, "{name} holds no link to the swatch its gradient came from"); + let r = run(s, "shape.rectangle", json!({"x": 100, "y": 0, "width": 100, "height": 60})); + let id = r["id"].as_u64().unwrap(); + run(s, "paint.setFill", json!({"ids": [id], "swatch": name})); + let fill = doc(s).node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); + let Paint::Gradient(g) = fill else { panic!("{name}: a gradient fill") }; + let geom = g.geom.unwrap(); + assert!( + geom.start.distance(vectorcraft_geom::Point::new(150.0, 30.0)) < 1e-9 && (geom.aspect - 0.5).abs() < 1e-9, + "{name} fits the object with its aspect: {geom:?}" + ); +} + +/// An edited gradient with a vector and an aspect that replaces a gradient swatch keeps its aspect, +/// as a new swatch made from it does. +#[test] +fn a_gradient_swatch_takes_an_edited_gradient_with_its_aspect() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Edited", "gradient": radial_gradient(json!({}))})); + let placed = radial_gradient(json!({"start": [25, 25], "end": [50, 25], "aspect": 50})); + run(&mut s, "swatch.edit", json!({"name": "Edited", "paint": {"gradient": placed}})); + assert_swatch_applies_with_half_aspect(&mut s, "Edited"); +} + +/// A gradient swatch Alt-dropped on another gradient swatch gives it its gradient with its aspect. +#[test] +fn a_gradient_swatch_takes_a_dropped_swatch_with_its_aspect() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Dropped", "gradient": radial_gradient(json!({}))})); + let placed = radial_gradient(json!({"start": [25, 25], "end": [50, 25], "aspect": 50})); + run(&mut s, "swatch.new", json!({"name": "Oval", "gradient": placed})); + run(&mut s, "swatch.edit", json!({"name": "Dropped", "paint": {"swatch": "Oval"}})); + assert_swatch_applies_with_half_aspect(&mut s, "Dropped"); +} + +/// A gradient given an aspect but no vector keeps the aspect in a new swatch and in a replacement, +/// as `paint.setFill` keeps it on the object. +#[test] +fn a_gradient_without_a_vector_keeps_its_aspect_in_a_swatch() { + let mut s = session(); + let oval = radial_gradient(json!({"aspect": 50})); + run(&mut s, "swatch.new", json!({"name": "New", "gradient": oval})); + assert_swatch_applies_with_half_aspect(&mut s, "New"); + run(&mut s, "swatch.new", json!({"name": "Edited", "gradient": radial_gradient(json!({}))})); + run(&mut s, "swatch.edit", json!({"name": "Edited", "paint": {"gradient": oval}})); + assert_swatch_applies_with_half_aspect(&mut s, "Edited"); + // Without an aspect the swatch stays unplaced. + run(&mut s, "swatch.new", json!({"name": "Round", "gradient": radial_gradient(json!({}))})); + let Paint::Gradient(round) = &doc(&s).swatch("Round").unwrap().paint else { panic!("a gradient swatch") }; + assert_eq!(round.geom, None); +} + +/// A pattern that replaces a pattern swatch is stored untransformed, as a new swatch stores it, so +/// art the swatch is applied to keeps its own tile placement. +#[test] +fn a_pattern_swatch_takes_a_new_pattern_untransformed() { + let mut s = session(); + for (name, color) in [("Dots", "#ff0000"), ("Bars", "#0000ff")] { + let r = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + run(&mut s, "paint.setFill", json!({"ids": [r["id"]], "color": color})); + run(&mut s, "select.set", json!({"ids": [r["id"]]})); + run(&mut s, "object.pattern.make", json!({"name": name, "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + } + // A pattern swatch holding a tile transform, as documents saved by older versions can. + let moved = vectorcraft_geom::Affine::new([2.0, 0.0, 0.0, 2.0, 15.0, 10.0]); + let st = s.doc_mut().unwrap(); + let Some(Paint::Pattern { xf, .. }) = std::sync::Arc::make_mut(&mut st.doc).swatch_mut("Bars").map(|w| &mut w.paint) else { + panic!("the Bars pattern swatch") + }; + *xf = moved; + run(&mut s, "swatch.edit", json!({"name": "Dots", "paint": {"swatch": "Bars"}})); + assert_eq!(doc(&s).swatch("Dots").unwrap().paint, vectorcraft_doc::pattern::pattern_paint("Bars")); +} + +/// A pattern swatch takes a pattern given by name, as an Alt-drop of a pattern fill sends it, and +/// an unknown pattern is an error that leaves the swatch as it was. +#[test] +fn a_pattern_swatch_takes_a_pattern_by_name() { + let mut s = session(); + for (name, color) in [("Dots", "#ff0000"), ("Bars", "#0000ff")] { + let r = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + run(&mut s, "paint.setFill", json!({"ids": [r["id"]], "color": color})); + run(&mut s, "select.set", json!({"ids": [r["id"]]})); + run(&mut s, "object.pattern.make", json!({"name": name, "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + } + run(&mut s, "swatch.edit", json!({"name": "Dots", "paint": {"pattern": "Bars"}})); + assert_eq!(doc(&s).swatch("Dots").unwrap().paint, vectorcraft_doc::pattern::pattern_paint("Bars")); + let before = doc(&s).swatch("Dots").unwrap().paint.clone(); + assert!(s.execute("swatch.edit", &json!({"name": "Dots", "paint": {"pattern": "Nope"}})).is_err()); + assert_eq!(doc(&s).swatch("Dots").unwrap().paint, before); +} + #[test] fn organic_fades_add_with_their_method_midpoints_and_opacities_in_rgb_and_cmyk() { let lib = vectorcraft_color::libraries::builtin_library("organic-fades").unwrap(); diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index e8b3ab0906..b02e34690b 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -149,6 +149,34 @@ fn loading_an_assigned_profile_again_keeps_it_in_use() { assert_eq!(cms::working_settings(), working); } +/// A document behind the front one draws through its own assigned profile: loading that profile's +/// file again or removing it redraws the document, and the front document stays as it was. +#[test] +fn loading_or_removing_a_back_documents_profile_redraws_it() { + const NAME: &str = "Back RGB P"; + let _g = settings(); + let dir = fresh_dir("back-assigned"); + let _reset = ColorSettingsReset { profile: Some(NAME), paths: vec![dir.clone()] }; + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("back.icc"); + std::fs::write(&path, icc_named(NAME)).unwrap(); + let mut s = session(); + let load = |s: &mut Session| assert_eq!(exec(s, "color.loadProfile", json!({"path": path.to_str().unwrap()}))["name"], NAME); + load(&mut s); + exec(&mut s, "edit.assignProfile", json!({"rgb": NAME})); + exec(&mut s, "file.new", json!({"width": 100, "height": 100})); + let revisions = |s: &Session| s.documents().iter().map(|d| d.revision).collect::>(); + let before = revisions(&s); + load(&mut s); + let loaded = revisions(&s); + assert!(loaded[0] > before[0], "the back document assigned the profile redraws after a load"); + assert_eq!(loaded[1], before[1], "the front document does not use it"); + exec(&mut s, "color.removeProfile", json!({"name": NAME})); + let removed = revisions(&s); + assert!(removed[0] > loaded[0], "the back document assigned the profile redraws after a removal"); + assert_eq!(removed[1], loaded[1], "the front document does not use it"); +} + /// A batch that assigns a profile and then changes the color mode, as Document Setup's OK does, /// converts the colors through the profile it assigns. #[test] diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 79178d6233..7e613460cd 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4329,7 +4329,8 @@ proptest! { "0 R", "0 O", "0 1 1 0 K", "1 0 0 0 k", "0 0 0 0 1 0 0 Xa", "0 0 0 0 1 0 0 XA", "0.5 G", "0 g", "K", "Xa", "0 J 0 j 2 w 10 M []0 d", "-1 w", "1e400 w", "0 M", "[3 -1]0 d", "[]0 d", "1 0 Xd", "0 1 Xd", "1 1 Xd", "0 0 Xd", "1 Xd", "Xd", "0 1 0 0 0 Xy", "1 0.5 1 1 0 Xy", "Xy", - "0 Bb", "2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg", "0 BB", "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", + "0 Bb", "1 Bb", "2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg", "2 (Sky) 0 0 1e400 100 1 0 0 1 0 0 Bg", "2 (Dusk) 0 0 0 100 1 0 0 1 0 0 Bg", + "0 BB", "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P", "0 XR", ]).prop_map(|op| (op.to_string(), true)), "[ -~]{0,12}".prop_map(|text| (text, false)), @@ -4338,9 +4339,11 @@ proptest! { ) { use vectorcraft_testkit::ai; let close = if closed { "/Paint ;\n/Def ;\n" } else { "" }; - // C, a red 2 pt stroke, comes first, so that it reads whatever A holds. + // C, a red 2 pt stroke, comes first, so that it reads whatever A holds. The file defines the + // gradient Sky, which a gradient paint of A may name. let header = format!( - "%AI9_BeginArtStyles\n/KnownStyle :\n(C) /Name ,\n/SimpleStyle :\n0 R\n0 1 1 0 K\n2 w\n1 0 Xd\n/Paint ;\n/Def ;\n\ + "%AI5_BeginGradient: (Sky)\n(Sky) 0 2 Bd\n[\n%_0 0 0 0 0 1 0 2 1 6 50 0 Bs\n%_0 0 0 0 1 1 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n\ + %AI9_BeginArtStyles\n/KnownStyle :\n(C) /Name ,\n/SimpleStyle :\n0 R\n0 1 1 0 K\n2 w\n1 0 Xd\n/Paint ;\n/Def ;\n\ /KnownStyle :\n(A) /Name ,\n/SimpleStyle :\n{}\n{close}/KnownStyle :\n(B) /Name ,\n/SimpleStyle :\n1 0 Xd\n/Paint ;\n/Def ;\n\ %AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(A)\n(B)\n(C)\n%AI9_EndArtStyleList\n", ops.iter().map(|(op, _)| op.as_str()).collect::>().join("\n") diff --git a/crates/engine/tests/it/dimensions.rs b/crates/engine/tests/it/dimensions.rs index 56ee67be17..240a7fe280 100644 --- a/crates/engine/tests/it/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -419,6 +419,56 @@ fn a_reflected_horizontal_dimension_keeps_its_line_on_the_reflected_side() { assert_eq!(text(&s, id), "100.00 pt"); } +/// An unlinked aligned dimension reflected on its own has its line on the reflected side of its +/// points: a line 30 pt above a level pair is 30 pt below it after a flip top to bottom, and a +/// half turn moves it below them as well, by turning it. +#[test] +fn a_reflected_aligned_dimension_keeps_its_line_on_the_reflected_side() { + let mut s = session(); + let id = run(&mut s, "dimension.create", json!({"a":[100,200],"b":[200,200],"offset":-30}))["id"].as_u64().unwrap(); + assert_eq!(path_ends(&s, id)[2], vec![(100.0, 170.0), (200.0, 170.0)]); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]})); + assert_eq!(path_ends(&s, id)[2], vec![(100.0, 230.0), (200.0, 230.0)]); + assert_eq!(text(&s, id), "100.00 pt"); + // Reflected left to right, the points swap ends and the line stays below them. + run(&mut s, "object.reflect", json!({"axis":"vertical","origin":[150,0]})); + assert_eq!(path_ends(&s, id)[2], vec![(200.0, 230.0), (100.0, 230.0)]); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(path_ends(&s, id)[2], vec![(100.0, 170.0), (200.0, 170.0)]); + // A half turn about the points' midpoint puts the line below them by turning it. + run(&mut s, "object.rotate", json!({"angle":180,"origin":[150,200]})); + let ends = path_ends(&s, id)[2].iter().map(|(x, y)| (x.round(), y.round())).collect::>(); + assert_eq!(ends, vec![(200.0, 230.0), (100.0, 230.0)]); +} + +/// An unlinked aligned dimension of a diagonal pair reflected about a mirror line through its +/// points has its line on the other side of them, and a scale by a negative factor reverses the +/// side as a reflection does, while a scale by positive factors keeps it. +#[test] +fn an_aligned_dimension_reflected_about_an_angle_or_scaled_by_a_negative_factor_changes_sides() { + let mut s = session(); + let id = run(&mut s, "dimension.create", json!({"a":[100,200],"b":[200,100],"offset":-30}))["id"].as_u64().unwrap(); + let k = 30.0 / 2f64.sqrt(); + let near = |got: &[(f64, f64)], want: [(f64, f64); 2]| got.iter().zip(want).all(|(g, w)| (g.0 - w.0).abs() < 1e-6 && (g.1 - w.1).abs() < 1e-6); + let ends = path_ends(&s, id)[2].clone(); + assert!(near(&ends, [(100.0 - k, 200.0 - k), (200.0 - k, 100.0 - k)]), "{ends:?}"); + run(&mut s, "select.set", json!({"ids":[id]})); + // The mirror line at 45 degrees through (150, 150) holds both points. + run(&mut s, "object.reflect", json!({"axis":45,"origin":[150,150]})); + let ends = path_ends(&s, id)[2].clone(); + assert!(near(&ends, [(100.0 + k, 200.0 + k), (200.0 + k, 100.0 + k)]), "{ends:?}"); + run(&mut s, "edit.undo", json!({})); + let level = run(&mut s, "dimension.create", json!({"a":[100,200],"b":[200,200],"offset":-30}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[level]})); + run(&mut s, "object.scale", json!({"sx":-200,"sy":100,"origin":[150,0]})); + assert_eq!(path_ends(&s, level)[2], vec![(250.0, 170.0), (50.0, 170.0)]); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "object.scale", json!({"sx":200,"sy":200,"origin":[150,200]})); + assert_eq!(path_ends(&s, level)[2], vec![(50.0, 170.0), (250.0, 170.0)]); +} + /// The line of a linked horizontal dimension below both points stays below them when the art /// turns a half turn; a line between the points keeps its distance from the first point. #[test] diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 58cc302279..20546835c1 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -950,9 +950,10 @@ proptest! { prop_assert_eq!(theirs.setting("mine", m).unwrap(), Some(b"kept".to_vec())); prop_assert_eq!(theirs.cache_entry("mine", m).unwrap(), Some(b"kept".to_vec())); prop_assert_eq!(theirs.cache_bytes(), 4); - // The host names every file: the store holds the two modules, their grants and their folders. + // The host names every file: the store holds the two modules, their grants, their folders and + // the file windows lock while they change the store. let names = |dir: &std::path::Path| std::fs::read_dir(dir).unwrap().filter_map(Result::ok).map(|e| e.file_name()).collect::>(); - prop_assert!(names(&folder).len() <= 4, "{:?}", names(&folder)); + prop_assert!(names(&folder).len() <= 5, "{:?}", names(&folder)); prop_assert!(names(&folder.join("storage")).len() <= 2, "{:?}", names(&folder.join("storage"))); let _ = s.execute("plugin.remove", &json!({"id": "fuzz.storage"})); let _ = s.execute("plugin.remove", &json!({"id": "fuzz.storage.other"})); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 4779afaa27..ce64e56fd5 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -1187,6 +1187,28 @@ fn a_symbol_definition_is_written_only_with_room_for_its_instance() { } } +/// A symbol definition without room for its group, asked for by an instance in art drawn for an +/// object (a stained instance's art), is left out and leaves the writing of that art as it was. +#[test] +fn a_symbol_definition_without_room_in_drawn_art_is_left_out() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let dot = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(dot) }); + let inner = instance(&mut doc, "Dot", Affine::IDENTITY); + doc.symbols.push(Symbol { dynamic: false, name: "Badge".into(), art: Arc::new(inner) }); + let mut stained = instance(&mut doc, "Badge", Affine::translate((50.0, 10.0))); + stained.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + doc.insert(Some(layer), usize::MAX, stained).unwrap(); + let read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + for max in 0..read { + let out = export_within(&doc, &opts(), max).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "room for {max} of {read}"); + let objects = crate::import::objects_read(&out.program).unwrap(); + assert!(objects <= max, "{objects} objects read with room for {max}"); + } +} + /// The writer's limit is the reader's, which its warning names. #[test] fn the_object_limit_is_the_readers() { @@ -10563,14 +10585,15 @@ fn text_wrap_options_of_type_and_envelopes_are_read_back() { } /// A clipping path inside art cut to a clipping object is written as its fills and its strokes, -/// two objects: its text wrap options are written once, whether it has both or only strokes. +/// two objects: its text wrap options are written once, whether it has both or only strokes, and +/// with its strokes when the region outside leaves nothing of its fills. #[test] fn a_clipping_path_written_as_its_paint_keeps_one_text_wrap() { - for fill in [solid(Color::gray(0.5)), Paint::None] { + for (fill, left, width) in [(solid(Color::gray(0.5)), 20.0, 2.0), (Paint::None, 20.0, 2.0), (solid(Color::gray(0.5)), 101.0, 4.0)] { let mut doc = Document::new(200.0, 100.0); let layer = doc.layers[0].id; - let mut cutter = clipping(&mut doc, shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0))); - cutter.appearance = Appearance::basic(fill, solid(Color::BLACK), 2.0).into(); + let mut cutter = clipping(&mut doc, shapes::rectangle(Rect::new(left, 20.0, left + 60.0, 80.0))); + cutter.appearance = Appearance::basic(fill, solid(Color::BLACK), width).into(); let cutter = wrapped(cutter, 5.0, true); let member = square(&mut doc, Rect::new(30.0, 30.0, 120.0, 60.0), Color::rgb(1.0, 0.0, 0.0)); let inner = group(&mut doc, vec![cutter, member], true); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 64583d3949..6361efe5ff 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -1102,22 +1102,25 @@ impl<'a> Writer<'a> { /// A container's members, the first one's outline cutting the others: its fills painted /// behind them (cut to the region outside too), its strokes over them, not cut by it. Its text - /// wrap options are written once: with its fills, or without fills with its strokes. + /// wrap options are written once: with its fills, or with its strokes when no fill object is + /// written (it has no fills, or the region outside leaves nothing of them). fn clip_contents(&mut self, children: &[Arc]) { let Some((first, rest)) = children.split_first() else { return }; // Nothing to clip by hides the clipped art. let Some((outline, rule)) = vectorcraft_effects::clip_outline(first) else { return }; let mut paint = first.clip_paint(); - if let (Some(_), Some(stroke)) = (&paint.fill, &mut paint.stroke) - && stroke.extras().wrap.is_some() - { - stroke.extras_mut().wrap = None; - } let outer = self.clip.clone(); let region = self.cut(&PathData::from_bezpath(&outline), rule); self.clip = Some(region); if let Some(fill) = &paint.fill { + let objects = self.objects; self.node(fill); + if self.objects > objects + && let Some(stroke) = &mut paint.stroke + && stroke.extras().wrap.is_some() + { + stroke.extras_mut().wrap = None; + } } for c in rest { self.node(c); diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index d058c9b770..0c8d2c7d3b 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -233,6 +233,7 @@ impl Writer<'_> { if !self.take_objects(1) { self.defining_symbols.pop(); self.keep_since(before); + (self.in_look, self.drawn) = (in_look, drawn); return false; } // The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index a7451c8b99..ecae1e8954 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1147,7 +1147,7 @@ impl<'a> Reader<'a> { continue; } let Some(st) = self.styles.get(&name) else { continue }; - let Some(mut appearance) = appearance_from_art_style(st) else { continue }; + let Some(mut appearance) = appearance_from_art_style(st, &self.gradients) else { continue }; // A Large Canvas copy's styles are at its art's scale ([`Self::ensure_space`]). if let Some(s) = self.large_canvas.filter(|s| *s != 1.0) { appearance.scale_strokes(s); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 6b963a74ad..8c032d5d83 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1323,9 +1323,11 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p } /// A graphic style's appearance from art style `st`, on no object: `None` when the style isn't one -/// this reads, or paints with a gradient or a pattern (placed on each object as its drawn look has them). +/// this reads, paints with a pattern, or paints with a gradient that names no swatch of `gradients`. /// A style that is one paint is its fill and stroke ([`crate::import::ai::style::ArtStyle::one_paint`]). -pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) -> Option { +/// A style keeps only its gradients' names and angles, so each one's gradient, by VectorCraft's own +/// rule, is fitted to the bounds of whatever object the style is applied to, at its angle. +pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle, gradients: &HashMap) -> Option { use crate::import::ai::style::StyleItem; if !st.plain { return None; @@ -1341,8 +1343,15 @@ pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) // A graphic style's brush isn't checked against the file's brushes: it is left out. StyleItem::Stroke { brush: Some(_), .. } => return None, StyleItem::Stroke { .. } => stroke_layer(item)?, - StyleItem::Gradient { .. } | StyleItem::Pattern { .. } => return None, + StyleItem::Gradient { name, angle } => AppearanceItem::Fill(FillLayer::new(named_gradient(name.as_deref()?, *angle, gradients)?)), + StyleItem::Pattern { .. } => return None, }; + // A one-paint style's gradient stroke names its swatch (`style::OnePaint::items`). + if let AppearanceItem::Stroke(s) = &mut layer + && let Paint::Gradient(g) = &s.paint + { + s.paint = named_gradient(g.swatch.as_deref()?, g.angle, gradients)?; + } if let Some(look) = st.looks.get(n) { with_look(&mut layer, look); } @@ -1351,6 +1360,14 @@ pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) Some(Appearance { items, effects: st.effects.clone(), contents_index: st.contents_index }) } +/// The gradient of `gradients` named `name` at `angle` degrees, fitted to the bounds of the object it +/// paints (`None` when there is no such gradient). +fn named_gradient(name: &str, angle: f64, gradients: &HashMap) -> Option { + let mut g = vectorcraft_color::gradient::GradientPaint::new(gradients.get(name)?.clone()); + g.angle = angle; + Some(Paint::Gradient(Box::new(g))) +} + /// What the reader makes of art style `name` in `section` (the text between `%AI9_BeginArtStyles` /// and its end), for writers that check a style before they write it. #[derive(Clone, Debug)] @@ -1400,7 +1417,9 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P _ => None, }) .collect(); - let mut appearance = appearance_from_art_style(&st)?; + // No gradient is taken by name: on an object, one that isn't its own is placed where its drawn + // look has it. + let mut appearance = appearance_from_art_style(&st, &HashMap::new())?; for (item, brush) in appearance.items.iter_mut().zip(brushes) { if let (AppearanceItem::Stroke(s), Some(brush)) = (item, brush) { s.brush = Some(brush); diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 7d570fca65..e0068a52be 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -216,7 +216,8 @@ pub(super) struct ArtStyle { pub simple_pattern: Option, /// A style that is one paint: its fill and stroke, bottom first, each there when its /// `stroke fill Xd` turns it on (none when neither is on). `Err` when it turns on a fill or - /// stroke whose paint isn't read here (a gradient, a pattern, a color that isn't read) or has + /// stroke whose paint isn't read here (a gradient that names no swatch or isn't in a gradient + /// instance, a pattern, a color that isn't read) or has /// no `Xd`. `None` for a style with filters, whose paint is [`Self::items`]. pub one_paint: Option, ()>>, } @@ -313,6 +314,8 @@ struct Filter { gradient_of: Option<(Option, f64)>, /// `(name) … [matrix] p`, and the numbers between the name and the matrix. pattern: Option<(String, Option<[f64; 6]>, Vec)>, + /// Its last fill paint, which replaces those before it. + fill_last: LastPaint, /// The plug-in object it refers to (`/GObjRef : (name) (plug-in) /PluginObject`): its name and /// plug-in name. plugin_object: Option<(String, String)>, @@ -346,6 +349,7 @@ impl Default for Filter { gradient: false, gradient_of: None, pattern: None, + fill_last: LastPaint::None, plugin_object: None, xy: None, xy_group: None, @@ -354,6 +358,15 @@ impl Default for Filter { } } +/// Which paint a filter's paint operators set last. +#[derive(Clone, Copy, Debug, PartialEq)] +enum LastPaint { + None, + Color, + Gradient, + Pattern, +} + /// Most filters read in one style. const MAX_FILTERS: usize = 4096; @@ -662,7 +675,7 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V], names: &mut Names) { | "xz" => { let Some((p, _)) = paint::color_op_in(op, vals, names) else { return }; if op.ends_with(|c: char| c.is_ascii_lowercase()) { - f.fill = Some(p); + (f.fill, f.fill_last) = (Some(p), LastPaint::Color); } else { f.stroke = Some(p); } @@ -719,7 +732,7 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V], names: &mut Names) { } // `flag (name) x y angle length a b c d tx ty Bg`. "Bg" => { - f.gradient = true; + (f.gradient, f.fill_last) = (true, LastPaint::Gradient); let at = vals.iter().rposition(|v| matches!(v, V::Str(_))); let name = at.and_then(|i| vals.get(i)).and_then(|v| names.text(v)); let n: Vec = at.map_or(&[][..], |i| vals.get(i + 1..).unwrap_or_default()).iter().filter_map(V::num).collect(); @@ -728,7 +741,7 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V], names: &mut Names) { } "p" => { if let Some(pattern) = pattern_operands(vals, names) { - f.pattern = Some(pattern); + (f.pattern, f.fill_last) = (Some(pattern), LastPaint::Pattern); } } _ => {} @@ -786,6 +799,11 @@ struct OnePaint { stroke_unread: bool, /// Inside a gradient instance (`flag Bb` … `BB`): whether it paints the stroke (flag 1). instance: Option, + /// The gradient swatch's name and angle of its last fill paint, when that is a gradient + /// instance's `Bg`. + fill_gradient: Option<(String, f64)>, + /// The same for its last stroke paint. + stroke_gradient: Option<(String, f64)>, } impl OnePaint { @@ -811,9 +829,26 @@ impl OnePaint { _ => {} } paint_op(&mut self.paint, op, vals, names); + // A gradient instance's `Bg` names the swatch its fill or stroke paints with; any other + // paint of that side replaces it. Outside an instance, which side it paints isn't known. + let named = || match (op, &self.paint.gradient_of) { + ("Bg", Some((Some(name), angle))) => Some((name.clone(), *angle)), + _ => None, + }; + match op { + "Bg" if self.instance == Some(true) => self.stroke_gradient = named(), + "Bg" if self.instance == Some(false) => self.fill_gradient = named(), + "Bg" => (self.fill_gradient, self.stroke_gradient) = (None, None), + "p" | "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "xa" | "xx" | "xk" | "xs" | "xz" => self.fill_gradient = None, + "P" | "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" => self.stroke_gradient = None, + _ => {} + } } - /// Its fill and stroke ([`ArtStyle::one_paint`]). + /// Its fill and stroke ([`ArtStyle::one_paint`]). A gradient fill is its [`StyleItem::Gradient`]; + /// a gradient stroke's paint is a gradient that only names its swatch (`swatch`) at its angle, + /// with default stops: `read::ops`' `appearance_from_art_style` gives it the file's gradient of + /// that name. fn items(&self) -> Result, ()> { let f = &self.paint; if !self.xd { @@ -822,14 +857,22 @@ impl OnePaint { let (stroked, filled) = f.paints; let mut items = vec![]; if filled { - match &f.fill { - Some(p) if !self.fill_unread => items.push(StyleItem::Fill(p.clone())), + match (&f.fill, &self.fill_gradient) { + (Some(p), _) if !self.fill_unread => items.push(StyleItem::Fill(p.clone())), + (_, Some((name, angle))) if self.fill_unread => items.push(StyleItem::Gradient { name: Some(name.clone()), angle: *angle }), _ => return Err(()), } } if stroked { - match &f.stroke { - Some(p) if !self.stroke_unread => items.push(StyleItem::Stroke { + let named = self.stroke_gradient.as_ref().filter(|_| self.stroke_unread).map(|(name, angle)| { + Paint::Gradient(Box::new(vectorcraft_color::gradient::GradientPaint { + angle: *angle, + swatch: Some(name.clone()), + ..vectorcraft_color::gradient::GradientPaint::new(vectorcraft_color::gradient::Gradient::default()) + })) + }); + match named.as_ref().or(f.stroke.as_ref().filter(|_| !self.stroke_unread)) { + Some(p) => items.push(StyleItem::Stroke { paint: p.clone(), width: f.width, cap: f.cap, @@ -1396,6 +1439,8 @@ fn paint_item(f: &Filter) -> Result, ()> { match f.name.as_str() { FILL if !f.paints.1 => Ok(None), FILL => match (&f.pattern, &f.gradient_of, &f.fill) { + (.., Some(p)) if f.fill_last == LastPaint::Color => Ok(Some(StyleItem::Fill(p.clone()))), + (_, Some((name, angle)), _) if f.fill_last == LastPaint::Gradient => Ok(Some(StyleItem::Gradient { name: name.clone(), angle: *angle })), (Some((name, matrix, controls)), ..) => Ok(Some(StyleItem::Pattern { name: name.clone(), matrix: *matrix, controls: controls.clone() })), (None, Some((name, angle)), _) => Ok(Some(StyleItem::Gradient { name: name.clone(), angle: *angle })), (None, None, _) if f.gradient => Ok(Some(StyleItem::Gradient { name: None, angle: 0.0 })), diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index ea8428809c..c4b006fe2a 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -471,6 +471,28 @@ fn lettering() -> String { format!("/KnownStyle :\n(Lettering) /Name ,\n/ActiveStyle :\n{root}\n/Execution ;\n /Def ;\n") } +/// A fill filter's last paint is its fill: a color, a gradient or a pattern replaces whichever of the +/// others came before it. +#[test] +fn a_fill_filter_paints_with_its_last_paint() { + let gradient = "0 Bb\n2 (Sky) 0 0 30 1 1 0 0 1 0 0 1 Bg\n0 BB"; + let pattern = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"; + let color = "0 0 0 0 1 0 0 Xa"; + let items = |paint: String| { + let root = compound("Chain Style Filter", &[fill(&paint)]); + let data = format!("/KnownStyle :\n(S) /Name ,\n/ActiveStyle :\n{root}\n/Execution ;\n /Def ;\n"); + art_styles(data.as_bytes()).remove("S").map(|s| s.items).unwrap_or_default() + }; + for before in [gradient, pattern] { + assert!(matches!(items(format!("{before}\n{color}")).as_slice(), [StyleItem::Fill(Paint::Solid { .. })]), "{before}"); + } + for before in [color, pattern] { + assert_eq!(items(format!("{before}\n{gradient}")), [StyleItem::Gradient { name: Some("Sky".into()), angle: 30.0 }], "{before}"); + } + assert!(matches!(items(format!("{color}\n{pattern}")).as_slice(), [StyleItem::Pattern { .. }])); + assert!(matches!(items(format!("{gradient}\n{pattern}")).as_slice(), [StyleItem::Pattern { .. }])); +} + #[test] fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() { // GUARDS: more than one fill, or an effect on a fill, made the style not plain, and type @@ -1544,14 +1566,22 @@ fn a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on() { // A gradient instance's flag says which one it paints: a stroke gradient leaves a solid fill read. let fill_and_stroke_gradient = one("0 0 1 0 k\n1 Bb\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 1 Xd\n"); assert!(matches!(fill_and_stroke_gradient, Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Fill(_)]))); - // Not read: no `Xd`, a gradient or pattern fill, a pattern stroke, a gradient stroke after a solid one, a paint - // turned on with no color. + // A gradient instance's fill is its swatch at its angle; its stroke, after a solid one too, is a + // gradient naming its swatch at its angle, at the stroke's weight. + let gradient_fill = one("0 Bb\n2 (Sky) 0 0 30 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n"); + assert_eq!(gradient_fill, Some(Ok(vec![StyleItem::Gradient { name: Some("Sky".into()), angle: 30.0 }]))); + let gradient_stroke = one("0 R\n0 1 1 0 K\n1 Bb\n0 0 0 0 Bh\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n2 w\n1 0 Xd\n"); + let Some(Ok(items)) = &gradient_stroke else { panic!("{gradient_stroke:?}") }; + let [StyleItem::Stroke { paint: Paint::Gradient(g), width, .. }] = items.as_slice() else { panic!("{items:?}") }; + assert_eq!((g.swatch.as_deref(), g.angle, g.geom, *width), (Some("Ramp"), 90.0, None, 2.0)); + // Not read: no `Xd`, a pattern fill, a pattern stroke, a gradient outside a gradient instance, a + // paint turned on with no color. for paint in [ format!("0 0 1 0 k\n{stroke}"), - "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n".to_string(), "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 Xd\n".to_string(), "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n1 0 Xd\n".to_string(), - "0 R\n0 1 1 0 K\n1 Bb\n0 0 0 0 Bh\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n2 w\n1 0 Xd\n".to_string(), + "2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 1 Xd\n".to_string(), + "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 Xd\n".to_string(), "0 0 1 0 k\n1 1 Xd\n".to_string(), ] { assert_eq!(one(&paint), Some(Err(())), "{paint}"); diff --git a/crates/eps/src/import/predefined_cmap.rs b/crates/eps/src/import/predefined_cmap.rs index c688ebf9ae..46890af653 100644 --- a/crates/eps/src/import/predefined_cmap.rs +++ b/crates/eps/src/import/predefined_cmap.rs @@ -279,6 +279,7 @@ fn code_space(cmap: &str) -> Option { const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")]; const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")]; const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")]; + const JOHAB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x84\x41", b"\xd3\xfe"), (b"\xd8\x31", b"\xde\xfe"), (b"\xe0\x31", b"\xf9\xfe")]; let stem = match cmap { "H" | "V" => "", _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?, @@ -301,6 +302,7 @@ fn code_space(cmap: &str) -> Option { "CNS-EUC" => CNS_EUC, "KSCpc-EUC" => KSC_PC, "KSCms-UHC" => UHC, + "KSC-Johab" => JOHAB, _ => return None, }) } diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 8350cd514b..f63a77e09b 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -677,6 +677,10 @@ fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <09092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", // A byte that starts no row-and-cell code is a code of its own, also before a tab: three. "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <21092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", + // In the Johab code space each ASCII byte is a one-byte code and a Hangul code takes two + // bytes: three codes, across and down. + "/Ryumin-Light-KSC-Johab-H findfont 20 scalefont setfont <41428441> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", + "/Ryumin-Light-KSC-Johab-V findfont 20 scalefont setfont 10 100 moveto <41428441> show currentpoint 40 sub abs 0.01 lt exch 10 sub abs 0.01 lt and", ] { let r = super::tests::read(&format!("{cond} {{ 0 0 10 10 rectfill }} if")); assert!(all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "{cond}"); diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 8547c4d015..3eaf476489 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -1324,6 +1324,79 @@ fn the_page_limit_counts_a_repeated_images_samples_once() { assert!(page.body.len() + page.setup.len() <= inline + 1000 + 400, "{}", page.body.len()); } +/// Type whose color bitmap glyphs fit under the page limit but whose run fill passes it is left +/// out with its glyph image placements: the page is the page without that type. A glyph image +/// that other art draws once more is written inline there again, and an image that other art +/// draws twice keeps its shared samples. +#[test] +fn type_taken_back_for_the_page_limit_takes_its_glyph_images_with_it() { + use vectorcraft_doc::{CharStyle, TextObject}; + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font}; + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); + let black = Paint::solid(Color::rgb(0.0, 0.0, 0.0)); + let type_node = |d: &mut Document, at: Point, text: &str, fill: Paint| { + let st = CharStyle { font_family: colour_bitmap_family(false).into(), size: 32.0, fill, ..CharStyle::default() }; + Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(at, text, st)))) + }; + // Six glyph images, then outlines enough for the fill to pass the limit. + let target = format!("{}{}", COLOUR_CHAR.to_string().repeat(6), "W".repeat(40)); + // `shared`: an image drawn twice comes first; `before`: one glyph drawn with the type's image; + // `fill`: the type's run fill (`None`: no type). A red rectangle comes last. + let build = |shared: bool, before: bool, fill: Option| { + let mut d = if shared { + let mut d = image_doc(&noise(8, 8), 1.0); + place_again(&mut d, 0, Affine::translate((40.0, 0.0))); + d + } else { + Document::new(200.0, 200.0) + }; + let mut nodes = vec![]; + if before { + nodes.push(type_node(&mut d, Point::new(20.0, 40.0), &COLOUR_CHAR.to_string(), black.clone())); + } + if let Some(fill) = fill { + nodes.push(type_node(&mut d, Point::new(20.0, 100.0), &target, fill)); + } + nodes.push(red_rect(&mut d)); + for n in nodes { + d.insert(d.default_layer(), usize::MAX, n).unwrap(); + } + d + }; + for shared in [false, true] { + for before in [false, true] { + let case = format!("shared {shared}, before {before}"); + let (without, unfilled, filled) = + (build(shared, before, None), build(shared, before, Some(Paint::None)), build(shared, before, Some(black.clone()))); + let o = opts(&filled); + let glyph = usize::from(shared); + let fits = crate::scene::Scene::new(&unfilled, &o).run(); + let full = crate::scene::Scene::new(&filled, &o).run(); + assert!(fits.warnings.is_empty() && full.warnings.is_empty(), "{case}: {:?} {:?}", fits.warnings, full.warnings); + assert_eq!(reuses(&fits.body, glyph, o.level), 6 + usize::from(before), "{case}: {}", fits.body); + assert_eq!(reuses(&full.body, glyph, o.level), 6 + usize::from(before), "{case}: {}", full.body); + let (fits_len, full_len) = (fits.body.len() + fits.setup.len(), full.body.len() + full.setup.len()); + assert!(full_len > fits_len + 2000, "{case}: {fits_len} {full_len}"); + let limit = (fits_len + full_len) / 2; + // Without its fill the type fits, every glyph image with it. + let page = crate::scene::Scene::new(&unfilled, &o).with_max_body(limit).run(); + assert!(page.warnings.is_empty(), "{case}: {:?}", page.warnings); + assert_eq!((page.setup, page.body), (fits.setup, fits.body), "{case}"); + // With it the type is taken back after its glyph images were placed. + let page = crate::scene::Scene::new(&filled, &o).with_max_body(limit).run(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{case}: {:?}", page.warnings); + let alone = crate::scene::Scene::new(&without, &o).run(); + assert!(alone.warnings.is_empty(), "{case}: {:?}", alone.warnings); + assert_eq!(reuses(&page.body, glyph, o.level), 0, "{case}: {}", page.body); + assert!(!page.setup.contains(&format!("/VCim{glyph} ")), "{case}: {}", page.setup); + assert_eq!(page.body.matches("/Width 16 /Height 16").count(), usize::from(before), "{case}: {}", page.body); + assert_eq!(reuses(&page.body, 0, o.level), if shared { 2 } else { 0 }, "{case}: {}", page.body); + assert_eq!((&page.setup, &page.body), (&alone.setup, &alone.body), "{case}"); + checked_page(&page.body); + } + } +} + /// A freeform gradient of `points` (x, y and color). fn freeform(points: &[(f64, f64, Color)]) -> Paint { let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() }); diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index c32d2d6c6f..e801df4a7a 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -666,7 +666,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "learn.list", "List Tutorials", "", - "{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}", + "{} the Learn panel's skills in the order its Skills tab lists them, with the progress its rings, counts and check marks show: {skills: [{id, title, done: number of its tutorials finished, total: number of its tutorials, tutorials: [{id, title, steps: number of steps, finished: bool, completedSteps: [zero-based indexes of the steps marked done, every step once finished]}]}]}", ), ( "learn.skills", @@ -692,11 +692,17 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tutorial: id (see learn.list)} open that tutorial's practice sample as a new untitled document, which becomes the active one, and move the Learn view to the tutorial, as its View more button does; each call opens another copy and open documents stay as they are; a missing, non-string or unknown id is an error and changes nothing; returns {tutorial, index, title} of the new document", ), + ( + "learn.markStep", + "Mark Tutorial Step", + "", + "{tutorial: id (see learn.list), step: zero-based step number, complete?: bool} mark a step of a tutorial whose sample learn.openSample opened as done (true, omitted or null) or not done (false), as the check box before the step does; marking does not finish the tutorial; a missing, non-string or unknown id, a missing step or one the tutorial does not have, a complete that is not true or false, a tutorial whose sample was not opened, or a finished tutorial is an error and changes nothing; the preferences keep the marks; returns {tutorial, step, complete}", + ), ( "learn.finish", "Finish Tutorial", "", - "{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, as its Finish tutorial button does; finishing it again keeps one entry; a missing, non-string or unknown id, or a tutorial whose sample was not opened, is an error and changes nothing; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}", + "{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, with all its steps done, as its Finish tutorial button does; finishing it again keeps one entry, and needs no sample after a restart; a missing, non-string or unknown id, or a tutorial not finished whose sample was not opened, is an error and changes nothing; the preferences keep the tutorials finished; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}", ), ( "learn.next", @@ -704,6 +710,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tutorial: id (see learn.list)} show the Learn panel on the tutorial after a finished one in the same skill, as its Next Up button does, without opening a sample; a missing, non-string or unknown id, a tutorial not finished, or a skill's last tutorial is an error and changes nothing; returns {skill, tutorial, title} of the next tutorial", ), + ( + "learn.resetProgress", + "Reset Learn Progress", + "", + "{} clear the Learn panel's progress, every tutorial finished and every step marked done, as Reset progress under the list of skills does; open documents, opened practice samples and the Learn view stay as they are; returns what learn.list returns", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index d0158d8cd2..8110ce2db5 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -144,7 +144,8 @@ impl Exporter<'_> { self.rgb_in_cmyk.images.insert(key.to_string()); } let r = if cmyk_image { - crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None) + let icc = out.cmyk_image_icc(blob); + crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None, icc.as_deref()) } else { crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels()) }; @@ -191,7 +192,8 @@ impl Exporter<'_> { let doc = self.doc; let img = doc.image_blob(im).filter(|b| b.is_cmyk()).and_then(|blob| { let alpha = crate::images::mask_alpha(doc.image_blob(mi)?, im.width, im.height, m.clip)?; - let r = crate::images::recode_cmyk(blob, size, self.compression, interpolate, self.out.cmyk_pixels(), Some(alpha)); + let icc = self.out.cmyk_image_icc(blob); + let r = crate::images::recode_cmyk(blob, size, self.compression, interpolate, self.out.cmyk_pixels(), Some(alpha), icc.as_deref()); if let (Some(w), Some(_)) = (r.warning, &r.image) { self.warn(w); } @@ -313,10 +315,6 @@ pub(super) fn png_8_bit(bytes: &[u8]) -> Option> { Some(png) } -/// Most pixels an image may have to be embedded: twice what the app reads (64 million), so every image -/// it holds is still written. -const MAX_EMBED_PIXELS: u64 = 1 << 27; - /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), /// other formats decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG of 16 bits a /// sample is written at 8 bits ([`png_8_bit`]). @@ -324,7 +322,7 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee // The header's size first: the writer's decoders allocate what it declares, and the encoded // bytes of an image that is far larger than any the app reads may be a few kilobytes. let size = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions(); - if size.is_ok_and(|(w, h)| u64::from(w) * u64::from(h) > MAX_EMBED_PIXELS) { + if size.is_ok_and(|(w, h)| u64::from(w) * u64::from(h) > crate::images::MAX_EMBED_PIXELS) { return None; } let bytes = blob.bytes.as_ref().clone(); diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index 9f6d9d6906..ca70ef2695 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -271,7 +271,8 @@ impl Writer { PdfError::from(e) })?; if self.cmyk { - cmyk_blending(&mut bytes, self.out.tagged); + let profile = self.out.tagged.then(|| vectorcraft_color::cms::icc_bytes(self.out.cmyk_profile()).ok()).flatten(); + cmyk_blending(&mut bytes, self.out.tagged, profile.as_deref(), self.out.standard != Standard::PdfA2b); } let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only(); let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes }; @@ -285,12 +286,21 @@ impl Writer { /// its blending space (DeviceRGB, or sRGB when colours are `tagged`), so the group dictionaries /// are rewritten in place to DeviceCMYK (or the CMYK profile's space), keeping their length so the /// cross-reference offsets stay valid. Luminosity masks' groups keep RGB: mask luminance is that -/// of screen colours, as on screen. -fn cmyk_blending(pdf: &mut [u8], tagged: bool) { +/// of screen colours, as on screen. Tagged groups blend in the space of CMYK profile `profile` +/// (the one that tags CMYK colours) when the file has one. When its only CMYK spaces are of other +/// profiles (images' own), they blend in DeviceCMYK where `device` allows it, else they keep RGB; +/// without `profile`, they blend in the first CMYK space. +fn cmyk_blending(pdf: &mut [u8], tagged: bool, profile: Option<&[u8]>, device: bool) { let masks = luminosity_mask_groups(pdf); let mut spaces = vec![("/CS/DeviceRGB".to_string(), "/CS/DeviceCMYK".to_string())]; if tagged { - spaces.extend(icc_spaces(pdf)); + let own = profile.and_then(|icc| crate::output::tagging_stream(pdf, &crate::patch::Xref::read(pdf)?, icc)); + let without = match (profile, device) { + (None, _) => Without::FirstCmyk, + (Some(_), true) => Without::DeviceCmyk, + (Some(_), false) => Without::Rgb, + }; + spaces.extend(icc_spaces(pdf, own, without)); } let mut from = 0; while let Some(at) = find(pdf, b"/Group<<", from).map(|i| i + b"/Group".len()) { @@ -303,11 +313,22 @@ fn cmyk_blending(pdf: &mut [u8], tagged: bool) { } } +/// What tagged groups blend in when the file holds no space of the document's CMYK profile. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Without { + /// The first ICC-based CMYK space of the file. + FirstCmyk, + DeviceCmyk, + /// The RGB space they have. + Rgb, +} + /// The ICC-based RGB colour spaces of `pdf`, each with the ICC-based CMYK one to blend in instead, -/// as group `/CS` entries (`/CS 3 0 R`); none without a CMYK one. -fn icc_spaces(pdf: &[u8]) -> Vec<(String, String)> { +/// as group `/CS` entries (`/CS 3 0 R`): the one whose profile is stream `own`, else as `without` +/// says; none without a CMYK one. +fn icc_spaces(pdf: &[u8], own: Option, without: Without) -> Vec<(String, String)> { const ICC: &[u8] = b"[/ICCBased "; - let (mut rgb, mut cmyk) = (vec![], None); + let (mut rgb, mut cmyk, mut own_cmyk) = (vec![], None, None); let mut from = 0; while let Some(at) = find(pdf, ICC, from) { from = at + ICC.len(); @@ -319,11 +340,19 @@ fn icc_spaces(pdf: &[u8]) -> Vec<(String, String)> { let Some(head) = find(pdf, format!("\n{stream} 0 obj").as_bytes(), 0) else { continue }; let dict = pdf.get(head..find(pdf, b"stream", head).unwrap_or(pdf.len())).unwrap_or_default(); if find(dict, b"/N 4", 0).is_some() { + if own == Some(stream) { + own_cmyk.get_or_insert(format!("/CS {array} 0 R")); + } cmyk.get_or_insert(format!("/CS {array} 0 R")); } else if find(dict, b"/N 3", 0).is_some() { rgb.push(format!("/CS {array} 0 R")); } } + let cmyk = own_cmyk.or_else(|| match without { + Without::FirstCmyk => cmyk, + Without::DeviceCmyk => cmyk.map(|_| "/CS/DeviceCMYK".to_string()), + Without::Rgb => None, + }); let Some(cmyk) = cmyk else { return vec![] }; rgb.into_iter().map(|r| (r, cmyk.clone())).collect() } @@ -385,3 +414,27 @@ pub(super) fn date_time(t: i64) -> krilla::metadata::DateTime { .utc_offset_hour(0) .utc_offset_minute(0) } + +#[cfg(test)] +mod tests { + use super::{Without, icc_spaces}; + + /// Objects 1 to 3: an RGB ICC-based space (profile 4), then two CMYK ones (profiles 5, 6). + const SPACES: &[u8] = b"%PDF-1.7\n1 0 obj\n[/ICCBased 4 0 R]\nendobj\n2 0 obj\n[/ICCBased 5 0 R]\nendobj\n3 0 obj\n[/ICCBased 6 0 R]\nendobj\n\ + 4 0 obj\n<>stream\n\nendstream\nendobj\n5 0 obj\n<>stream\n\nendstream\nendobj\n\ + 6 0 obj\n<>stream\n\nendstream\nendobj\n"; + + #[test] + fn groups_blend_in_the_documents_cmyk_space_else_device_cmyk() { + let rgb = "/CS 1 0 R".to_string(); + // The space of the document's profile, wherever it is. + for without in [Without::FirstCmyk, Without::DeviceCmyk, Without::Rgb] { + assert_eq!(icc_spaces(SPACES, Some(6), without), [(rgb.clone(), "/CS 3 0 R".to_string())], "{without:?}"); + } + // Without it, DeviceCMYK, or RGB where DeviceCMYK isn't allowed, rather than another + // profile's space; the first CMYK space when the document's profile isn't known. + assert_eq!(icc_spaces(SPACES, None, Without::DeviceCmyk), [(rgb.clone(), "/CS/DeviceCMYK".to_string())]); + assert_eq!(icc_spaces(SPACES, None, Without::Rgb), []); + assert_eq!(icc_spaces(SPACES, None, Without::FirstCmyk), [(rgb, "/CS 2 0 R".to_string())]); + } +} diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 4139259583..d1382449e5 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -10,7 +10,9 @@ //! CMYK images ([`ImageBlob::cmyk`]) stay CMYK: a CMYK JPEG already as asked is embedded as it is //! when its numbers are kept; the others' ink amounts are resampled, converted when the Output //! settings convert CMYK colours ([`CmykPixels`]) and compressed again. CMYK JPEGs hold Adobe's -//! inverted samples, which the PDF writer's Decode array undoes. +//! inverted samples, which the PDF writer's Decode array undoes. A CMYK image whose numbers are +//! kept carries the CMYK profile it is given ([`cmyk_icc`]), whether it is embedded as it is or +//! written again; without one it is untagged, a JPEG's own `ICC_PROFILE` segments included. use std::io::Cursor; use std::sync::Arc; @@ -26,7 +28,8 @@ use vectorcraft_doc::cmyk::jpeg_components; use crate::output::{CmykPixels, Pixels}; use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec}; -/// Raw 8-bit pixels: colour samples (RGB, grey or CMYK) and an optional alpha channel. +/// Raw 8-bit pixels: colour samples (RGB, grey or CMYK) and an optional alpha channel, with the +/// ICC profile the colour samples are in, if any. #[derive(Clone, Hash)] struct Raw { color: Arc>, @@ -34,6 +37,7 @@ struct Raw { width: u32, height: u32, space: ImageColorspace, + icc: Option>>, } impl CustomImage for Raw { @@ -54,7 +58,7 @@ impl CustomImage for Raw { } fn icc_profile(&self) -> Option<&[u8]> { - None + self.icc.as_deref().map(Vec::as_slice) } fn color_space(&self) -> ImageColorspace { @@ -79,7 +83,7 @@ fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool, pixels: Op (px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ImageColorspace::Rgb) }; let alpha = px.iter().any(|p| p[3] < 255).then(|| Arc::new(px.iter().map(|p| p[3]).collect())); - Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, space }, interpolate).ok() + Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, space, icc: None }, interpolate).ok() } /// Are all of the RGBA pixels `rgba` grey? @@ -88,9 +92,57 @@ fn neutral(rgba: &[u8]) -> bool { } /// Colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) with an optional `alpha` -/// channel, written losslessly. -fn lossless(samples: Vec, alpha: Option>, space: ImageColorspace, width: u32, height: u32, interpolate: bool) -> Option { - Image::from_custom(Raw { color: Arc::new(samples), alpha: alpha.map(Arc::new), width, height, space }, interpolate).ok() +/// channel, written losslessly, tagged with ICC profile `icc` when given. +fn lossless( + samples: Vec, + alpha: Option>, + space: ImageColorspace, + width: u32, + height: u32, + interpolate: bool, + icc: Option<&[u8]>, +) -> Option { + let icc = icc.map(|icc| Arc::new(icc.to_vec())); + Image::from_custom(Raw { color: Arc::new(samples), alpha: alpha.map(Arc::new), width, height, space, icc }, interpolate).ok() +} + +/// Most pixels an image may have to be embedded: twice what the app reads (64 million), so every image +/// it holds is still written. +pub(crate) const MAX_EMBED_PIXELS: u64 = 1 << 27; + +/// The most bytes a CMYK profile carried by an image is written with (16 MiB). +const MAX_ICC: usize = 16 << 20; + +/// The CMYK ICC profile image `blob` carries ([`ImageBlob::cmyk_icc_profile`]), when it is one a +/// CMYK image can be tagged with: at most [`MAX_ICC`] bytes, the size its header declares, the +/// `acsp` signature, an input, display, output or colour space profile of CMYK colours, and a tag +/// table whose tags lie within it. `None` for other images and other profiles. +pub(crate) fn cmyk_icc(blob: &ImageBlob) -> Option> { + blob.cmyk_icc_profile().filter(|icc| valid_cmyk_icc(icc)) +} + +/// Is `icc` a CMYK profile as [`cmyk_icc`] describes? +fn valid_cmyk_icc(icc: &[u8]) -> bool { + let be = |at: usize| icc.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); + let fits = |at: u32, len: u32| u64::from(at) + u64::from(len) <= icc.len() as u64; + if icc.len() > MAX_ICC || be(0).and_then(|n| usize::try_from(n).ok()) != Some(icc.len()) { + return false; + } + let class = icc.get(12..16); + if icc.get(36..40) != Some(b"acsp".as_slice()) + || icc.get(16..20) != Some(b"CMYK".as_slice()) + || ![b"scnr", b"mntr", b"prtr", b"spac"].iter().any(|c| class == Some(c.as_slice())) + { + return false; + } + let Some(count) = be(128) else { return false }; + if !fits(132, count.saturating_mul(12)) { + return false; + } + (0..count as usize).all(|i| { + let at = 132 + i * 12; + matches!((be(at + 4), be(at + 8)), (Some(offset), Some(len)) if fits(offset, len)) + }) } /// [`from_rgba`] of pixels converted by `pixels` (when given). @@ -114,7 +166,7 @@ pub(crate) fn from_inks(inks: Vec, alpha: Vec, width: u32, height: u32, Some(p) => p.convert(&inks), None => (inks, ImageColorspace::Cmyk), }; - lossless(samples, alpha, space, width, height, interpolate) + lossless(samples, alpha, space, width, height, interpolate, None) } /// The kinds of images the Compression section has settings for. @@ -291,7 +343,7 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in None if grey => (rgba.pixels().map(|p| p[0]).collect(), ImageColorspace::Luma), None => (rgba.pixels().flat_map(|p| [p[0], p[1], p[2]]).collect(), ImageColorspace::Rgb), }; - to_jpeg(&samples, space, width, height, q, interpolate) + to_jpeg(&samples, space, width, height, q, interpolate, None) }); let image = jpeg.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate, convert)); Recoded { image, warning: rule.instead } @@ -323,7 +375,8 @@ pub(crate) fn coverage_sized(w: u32, h: u32, cover: Vec, size: (f64, f64), c /// is when its numbers are (`convert` is `None`); otherwise its ink amounts are resampled, /// converted by `convert` and compressed again. With `alpha` (one sample a pixel, see /// [`mask_alpha`]) the image is lossless and carries it as its soft mask; `None` then when its -/// inks can't be read. +/// inks can't be read. When its numbers are kept, the image is tagged with CMYK profile `icc` +/// (see [`cmyk_icc`]), and is untagged without one; converted pixels are never tagged with it. pub(crate) fn recode_cmyk( blob: &ImageBlob, size: (f64, f64), @@ -331,13 +384,20 @@ pub(crate) fn recode_cmyk( interpolate: bool, convert: Option<&CmykPixels>, alpha: Option>, + icc: Option<&[u8]>, ) -> Recoded { let rule = rule(c, Kind::Color); let jpeg = jpeg_components(&blob.bytes).is_some(); + let icc = icc.filter(|_| convert.is_none()); if jpeg && convert.is_none() && alpha.is_none() { let dims = ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()); - if dims.is_some_and(|d| kept(&rule, d, size, true)) { - return Recoded { image: None, warning: rule.instead }; + if let Some((w, h)) = dims.filter(|d| kept(&rule, *d, size, true)) { + // The JPEG data as it is, tagged with `icc` rather than with its own segments; one + // larger than [`MAX_EMBED_PIXELS`] is not written. + let image = (u64::from(w) * u64::from(h) <= MAX_EMBED_PIXELS) + .then(|| Image::from_jpeg_with_icc(blob.bytes.as_ref().clone().into(), icc.map(|i| i.to_vec().into()), interpolate).ok()) + .flatten(); + return Recoded { image, warning: rule.instead }; } } let Some(inks) = blob.cmyk() else { @@ -360,8 +420,8 @@ pub(crate) fn recode_cmyk( None => (inks, ImageColorspace::Cmyk), }; // JPEG needs opaque pixels. - let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate)); - let image = image.or_else(|| lossless(samples, alpha, space, width, height, interpolate)); + let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate, icc)); + let image = image.or_else(|| lossless(samples, alpha, space, width, height, interpolate, icc)); Recoded { image, warning: rule.instead } } @@ -405,9 +465,9 @@ pub(crate) fn mask_alpha(blob: &ImageBlob, width: u32, height: u32, clip: bool) } /// Opaque colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) as a JPEG at -/// `quality`. CMYK JPEGs hold Adobe's inverted samples, which the PDF writer's Decode array -/// undoes. -fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, quality: u8, interpolate: bool) -> Option { +/// `quality`, tagged with ICC profile `icc` when given. CMYK JPEGs hold Adobe's inverted samples, +/// which the PDF writer's Decode array undoes. +fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, quality: u8, interpolate: bool, icc: Option<&[u8]>) -> Option { let mut out = Vec::new(); match space { ImageColorspace::Cmyk => { @@ -420,5 +480,5 @@ fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, qual } ImageColorspace::DeviceN => return None, } - Image::from_jpeg(out.into(), interpolate).ok() + Image::from_jpeg_with_icc(out.into(), icc.map(|i| i.to_vec().into()), interpolate).ok() } diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 77bc7d1d66..8114931bc4 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -18,7 +18,9 @@ //! document's), RGB with sRGB (colours of another RGB space as their sRGB equivalents) and grey //! with the sRGB tone curve. Otherwise they are device colours. PDF/A, PDF/X-3 and PDF/X-4 //! always tag them, whatever `profiles` says; PDF/X-1a never does, and always converts to a -//! CMYK destination (see [`crate::pdfx`]). +//! CMYK destination (see [`crate::pdfx`]). A CMYK image that keeps its numbers is tagged with +//! the CMYK profile it carries, if any, with `all` and `taggedSource`, and in PDF/A, PDF/X-3 +//! and PDF/X-4 unless `profiles` is `destination` ([`ColorOut::cmyk_image_icc`]). //! - Output intent: the profile `outputIntent` names, embedded as the `/GTS_PDFX` output intent //! of the catalog with the condition, identifier and registry; Trapped goes in the document //! information (`/False` when an output intent is written untrapped). PDF/A files carry their @@ -59,6 +61,9 @@ pub(crate) struct ColorOut { dest: Option, /// Colours are written in ICC-based colour spaces. pub tagged: bool, + /// CMYK images that keep their numbers are tagged with the CMYK profile they carry + /// ([`Self::cmyk_image_icc`]). + image_icc: bool, /// The CMYK profile that tags CMYK colours. cmyk_profile: String, /// Tagged RGB colours are written as their sRGB equivalents: why (a warning), when their RGB @@ -105,6 +110,7 @@ impl Default for ColorOut { profile_notes: vec![], dest: None, tagged: false, + image_icc: false, cmyk_profile, srgb_note: None, pixels: OnceLock::new(), @@ -240,6 +246,14 @@ impl ColorOut { ProfileInclusion::All | ProfileInclusion::Destination => true, ProfileInclusion::TaggedSource => !doc.color_profiles.is_empty(), }); + // Source profiles: with `all` and `taggedSource`, and in the files that tag every colour + // unless only the destination profile is asked for; never in PDF/X-1a. + out.image_icc = !set.standard.cmyk_only() + && match set.output.profiles { + ProfileInclusion::All | ProfileInclusion::TaggedSource => true, + ProfileInclusion::None => matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4), + ProfileInclusion::Destination => false, + }; let rgb = match &out.dest { Some(d) if d.model == Model::Rgb => (d.srgb, d.cms.settings().rgb.clone()), _ => (source.rgb_is_srgb(), st.rgb.clone()), @@ -324,6 +338,16 @@ impl ColorOut { .as_ref() } + /// The CMYK profile CMYK image `blob` is tagged with ([`crate::images::cmyk_icc`]): the one it + /// carries, when its numbers are kept and the Profiles setting or the standard includes source + /// profiles. `None` otherwise, and in PDF/X-1a, whose images are untagged CMYK. + pub(crate) fn cmyk_image_icc(&self, blob: &vectorcraft_doc::ImageBlob) -> Option> { + if !self.image_icc || self.cmyk_pixels().is_some() { + return None; + } + crate::images::cmyk_icc(blob) + } + /// How CMYK images' ink amounts are converted; `None` when they keep their numbers (no /// conversion, or to a CMYK destination that keeps them). pub(crate) fn cmyk_pixels(&self) -> Option<&CmykPixels> { @@ -477,7 +501,7 @@ fn catalog(set: &PdfSettings, cmyk_profile: &str) -> Option { /// The object number of the stream of an ICC-based colour space of `pdf` that holds profile `icc` /// (its data as it is, or Flate-compressed); `None` when no colour space uses that profile. -fn tagging_stream(pdf: &[u8], xref: &Xref, icc: &[u8]) -> Option { +pub(crate) fn tagging_stream(pdf: &[u8], xref: &Xref, icc: &[u8]) -> Option { use std::io::Read; const ICC: &[u8] = b"[/ICCBased "; let mut from = 0; diff --git a/crates/pdf/src/print/plates.rs b/crates/pdf/src/print/plates.rs index ce6c5b336e..8b0876d9f3 100644 --- a/crates/pdf/src/print/plates.rs +++ b/crates/pdf/src/print/plates.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use vectorcraft_color::Color; use vectorcraft_color::cms::{self, Cms, Model, ProfileKind}; use vectorcraft_color::swatch::REGISTRATION; -use vectorcraft_doc::inks::{Link, inks, plates, visit_node_colors}; +use vectorcraft_doc::inks::{Link, StopPlates, inks, plates, spot_color, stop_inks, visit_node_colors}; use vectorcraft_doc::overprint::darken_overprints; use vectorcraft_doc::{ColorMode, Document, ImageBlob, Node, NodeKind}; @@ -101,12 +101,33 @@ impl<'a> Separator<'a> { inks(self.doc, &self.cms, c, link, color.intent).on_plate(plate) } - /// Recolour `n`'s subtree as plate `plate`: the grey of each colour's coverage, unlinked. + /// [`Self::ink`] of a colour that prints process plates `plates` with its spot tint + /// ([`stop_inks`]): the spot plate prints the tint and the process plates print `plates`. With + /// spots as process, each process plate prints `plates` layered with the spot tint's process + /// equivalent, as a separated image's plates layer ([`vectorcraft_doc::separated::Separated::coverage_as`]). + fn stop_ink(&self, plate: &str, c: &Color, link: Link, plates: StopPlates) -> f32 { + let own = stop_inks(self.doc, &self.cms, c, link, plates, self.set.color.intent); + let (Some(_), Some((name, tint))) = (plates, own.spot.as_ref()) else { + return self.ink(plate, c, link); + }; + if !self.set.output.spots_to_process { + return own.on_plate(plate); + } + let Some(i) = cms::PROCESS_PLATES.iter().position(|p| *p == plate) else { + return 0.0; + }; + let spot = spot_color(self.doc, name).map_or(0.0, |s| self.ink(plate, &s.tinted(*tint), None)); + 1.0 - (1.0 - own.cmyk[i]) * (1.0 - spot) + } + + /// Recolour `n`'s subtree as plate `plate`: the grey of each colour's coverage, unlinked and + /// without process plates. pub fn node(&self, plate: &str, n: &mut Node) { - visit_node_colors(n, &mut |c, swatch, tint| { - *c = Color::Gray { k: self.ink(plate, c, swatch.as_deref().map(|s| (s, *tint))) }; + visit_node_colors(n, &mut |c, swatch, tint, plates| { + *c = Color::Gray { k: self.stop_ink(plate, c, swatch.as_deref().map(|s| (s, *tint)), *plates) }; *swatch = None; *tint = 1.0; + *plates = None; }); } diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 45222bb04d..4719beec70 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -75,16 +75,18 @@ fn cmyk_dict(w: u32, h: u32, more: &str) -> String { const INVERTED: &str = "/Decode [1 0 1 0 1 0 1 0]"; -/// An image XObject of a written PDF: its colour space and the components of its ICC profile, -/// its Decode array, its filter and its stream data. +/// An image XObject of a written PDF: its colour space and the components and bytes of its ICC +/// profile, its Decode array, its filter, its stream data and whether it has a soft mask. #[derive(Debug)] struct Written { space: String, icc_n: Option, + icc: Option>, decode: Vec, filter: Option, data: Vec, width: u32, + smask: bool, } fn written(pdf: &[u8]) -> Vec { @@ -96,22 +98,26 @@ fn written(pdf: &[u8]) -> Vec { if d.get::>(b"Subtype").is_none_or(|n| n.as_ref() != b"Image") { continue; } - let (space, icc_n) = match (d.get::>(b"ColorSpace"), d.get::>(b"ColorSpace")) { - (Some(n), _) => (n.as_str().to_string(), None), + let (space, icc_n, icc) = match (d.get::>(b"ColorSpace"), d.get::>(b"ColorSpace")) { + (Some(n), _) => (n.as_str().to_string(), None, None), (_, Some(a)) => { let mut it = a.flex_iter(); let name = it.next::>().unwrap().as_str().to_string(); - (name, it.next::>().and_then(|s| s.dict().get::(b"N"))) + let profile = it.next::>(); + let n = profile.as_ref().and_then(|s| s.dict().get::(b"N")); + (name, n, profile.map(|s| s.decoded().unwrap().into_owned())) } - _ => (String::new(), None), + _ => (String::new(), None, None), }; out.push(Written { space, icc_n, + icc, decode: d.get::>(b"Decode").map(|a| a.iter::().collect()).unwrap_or_default(), filter: d.get::>(b"Filter").map(|n| n.as_str().to_string()), data: s.raw_data().to_vec(), width: d.get::(b"Width").unwrap_or(0), + smask: d.get::>(b"SMask").is_some(), }); } out @@ -447,6 +453,222 @@ fn the_output_settings_convert_cmyk_images_and_compression_recompresses_them_in_ assert_eq!(blob.cmyk().unwrap().data, flat(ink, 16)); } +/// A version 2.1 output profile of CMYK colors with a one-tag table (`cprt`), its header and table +/// consistent with its size; `edit` changes it after it is made. +fn cmyk_profile(edit: impl FnOnce(&mut Vec)) -> Vec { + let mut icc = vec![0u8; 156]; + icc[0..4].copy_from_slice(&156u32.to_be_bytes()); + icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]); + icc[12..16].copy_from_slice(b"prtr"); + icc[16..20].copy_from_slice(b"CMYK"); + icc[20..24].copy_from_slice(b"Lab "); + icc[36..40].copy_from_slice(b"acsp"); + icc[128..132].copy_from_slice(&1u32.to_be_bytes()); + icc[132..136].copy_from_slice(b"cprt"); + icc[136..140].copy_from_slice(&144u32.to_be_bytes()); + icc[140..144].copy_from_slice(&12u32.to_be_bytes()); + icc[144..156].copy_from_slice(b"text\0\0\0\0VC\0\0"); + edit(&mut icc); + icc +} + +/// `inks` (four bytes a pixel) as a CMYK JPEG carrying `icc` in its `ICC_PROFILE` segments. +fn cmyk_jpeg_with(inks: &[u8], w: u16, h: u16, icc: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut e = jpeg_encoder::Encoder::new(&mut out, 100); + e.add_icc_profile(icc).unwrap(); + e.encode(inks, w, h, jpeg_encoder::ColorType::Cmyk).unwrap(); + out +} + +/// A CMYK TIFF of `w` × `h` pixels of `ink` carrying `icc` as its tag 34675 (written as Open +/// stores a CMYK JPEG that carries one). +fn cmyk_tiff_with(ink: [u8; 4], w: u16, h: u16, icc: &[u8]) -> ImageBlob { + let jpg = cmyk_jpeg_with(&flat(ink, usize::from(w) * usize::from(h)), w, h, icc); + let (tiff, _) = vectorcraft_doc::cmyk::turned_cmyk(&jpg, image::metadata::Orientation::NoTransforms, None).unwrap(); + let blob = ImageBlob::new("image/tiff", tiff); + assert_eq!(blob.cmyk_icc_profile().as_deref(), Some(icc), "the TIFF carries the profile"); + blob +} + +/// A CMYK image whose numbers are kept is written in an ICC-based space of the CMYK profile it +/// carries, byte for byte, when the Profiles setting or the standard includes source profiles: +/// a TIFF written again, a JPEG kept as it is or compressed again, and an image with its mask as +/// its soft mask. Its inks are those it had. Without source profiles it is DeviceCMYK, and a +/// converted image never carries the profile its old numbers were in. +#[test] +fn a_cmyk_image_that_keeps_its_numbers_carries_its_cmyk_profile() { + let ink = [0u8, 102, 255, 0]; + let icc = cmyk_profile(|_| {}); + let tiff = cmyk_tiff_with(ink, 4, 4, &icc); + let tiff_inks = tiff.cmyk().unwrap().data; + let d = image_doc(tiff, 4, 4); + let export = |d: &Document, s: serde_json::Value| { + let out = export_with_report(d, &opts(s.clone())).unwrap_or_else(|e| panic!("{s}: {e}")); + let w: Vec = written(&out.bytes).into_iter().filter(|w| w.space != "DeviceGray").collect(); + assert_eq!(w.len(), 1, "{s}: {w:?}"); + (w.into_iter().next().unwrap(), out.bytes) + }; + let tagged = |w: &Written, s: &serde_json::Value| { + assert_eq!((w.space.as_str(), w.icc_n), ("ICCBased", Some(4)), "{s}"); + assert!(w.icc.as_deref() == Some(icc.as_slice()), "{s}: the image's own profile, byte for byte"); + }; + for s in [ + json!({"output": {"profiles": "all"}}), + json!({"output": {"profiles": "taggedSource"}}), + json!({"standard": "pdfX4"}), + json!({"standard": "pdfX3"}), + json!({"output": {"profiles": "all", "conversion": "preserveNumbers", "destination": DEVICE_CMYK}}), + ] { + let (w, bytes) = export(&d, s.clone()); + tagged(&w, &s); + assert_eq!(blobs(&import(&bytes).unwrap())[0].cmyk().unwrap().data, tiff_inks, "{s}: the inks as they were"); + } + // Without source profiles, untagged; PDF/X-1a images are always DeviceCMYK. + for s in [json!({}), json!({"output": {"profiles": "destination"}}), json!({"standard": "pdfX1a", "output": {"profiles": "all"}})] { + let (w, _) = export(&d, s.clone()); + assert!(w.icc.as_deref() != Some(icc.as_slice()), "{s}: {w:?}"); + } + let (w, _) = export(&d, json!({"standard": "pdfX1a", "output": {"profiles": "all"}})); + assert_eq!(w.space, "DeviceCMYK"); + // Converted to another CMYK profile: its new numbers aren't in the old profile. + let (w, _) = export(&d, json!({"output": {"profiles": "all", "conversion": "destination", "destination": DEVICE_CMYK}})); + assert!(w.icc.as_deref() != Some(icc.as_slice()), "{w:?}"); + + // A CMYK JPEG kept as it is carries the profile only when source profiles are included, and + // PDF/X-1a writes it untagged. + let jpg = cmyk_jpeg_with(&flat(ink, 64 * 64), 64, 64, &icc); + let d = image_doc(ImageBlob::new("image/jpeg", jpg.clone()), 64, 64); + let s = json!({"output": {"profiles": "all"}}); + let (w, _) = export(&d, s.clone()); + tagged(&w, &s); + assert!(w.data == jpg, "the JPEG data unchanged"); + for s in [json!({"standard": "pdfX1a"}), json!({})] { + let (w, _) = export(&d, s.clone()); + assert_eq!((w.space.as_str(), w.filter.as_deref()), ("DeviceCMYK", Some("DCTDecode")), "{s}"); + assert!(w.data == jpg, "{s}: the JPEG data unchanged"); + } + // Resampled and compressed again, it keeps the profile. + let s = json!({"output": {"profiles": "all"}, "compression": {"color": {"downsample": "bicubic", "ppi": 72.0, "abovePpi": 100.0}}}); + let (w, bytes) = export(&d, s.clone()); + tagged(&w, &s); + assert_eq!((w.width, w.filter.as_deref()), (40, Some("DCTDecode")), "{s}"); + assert!(blobs(&import(&bytes).unwrap())[0].cmyk().unwrap().data.as_chunks::<4>().0.iter().all(|p| close(p, &ink, 3))); + + // With its mask as its soft mask. + let mut d = masked_doc(png(image::GrayImage::from_raw(2, 2, vec![255, 128, 0, 64]).unwrap()), true, false); + d.images.insert("img".into(), cmyk_tiff_with(ink, 2, 2, &icc)); + let s = json!({"output": {"profiles": "all"}}); + let (w, bytes) = export(&d, s.clone()); + tagged(&w, &s); + assert!(w.smask, "{w:?}"); + assert!(!String::from_utf8_lossy(&bytes).contains("/S/Luminosity"), "no luminosity soft mask"); +} + +/// A CMYK JPEG kept as it is whose header declares far more pixels than the app reads is left out +/// with a warning, as other images that large are. +#[test] +fn a_kept_cmyk_jpeg_declaring_too_many_pixels_is_left_out() { + let mut jpg = cmyk_jpeg_with(&flat([0, 102, 255, 0], 4), 2, 2, &cmyk_profile(|_| {})); + // Height and width of the SOF0 segment (after its marker, length and precision): 16384 × 16384. + let sof = jpg.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap(); + jpg[sof + 5..sof + 9].copy_from_slice(&[0x40, 0, 0x40, 0]); + assert_eq!(ImageBlob::new("image/jpeg", jpg.clone()).header_size(), Some((16384, 16384))); + let d = image_doc(ImageBlob::new("image/jpeg", jpg), 2, 2); + for s in [ + json!({"compression": {"color": {"downsample": "none"}}}), + json!({"output": {"profiles": "all"}, "compression": {"color": {"downsample": "none"}}}), + ] { + let out = export_with_report(&d, &opts(s.clone())).unwrap(); + assert!(written(&out.bytes).is_empty(), "{s}"); + assert!(out.warnings.iter().any(|w| w.contains("couldn't be decoded")), "{s}: {:?}", out.warnings); + } +} + +/// The profile each transparency group of written PDF `bytes` blends in: its ICC-based space's +/// profile, or `None` for DeviceCMYK. +fn group_profiles(bytes: &[u8]) -> Vec>> { + let text = String::from_utf8_lossy(bytes); + let file = Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let groups: Vec<&str> = text.split("/Group<<").skip(1).filter(|g| g.contains("/S/Transparency")).collect(); + assert!(!groups.is_empty(), "{text}"); + groups + .into_iter() + .map(|group| { + let cs = group.split("/CS").nth(1).unwrap(); + if cs.starts_with("/DeviceCMYK") { + return None; + } + let cs: i32 = cs.trim_start().split(' ').next().unwrap().parse().unwrap(); + let space = text.split(&format!("\n{cs} 0 obj\n")).nth(1).unwrap(); + let stream: i32 = space.strip_prefix("[/ICCBased ").unwrap().split(' ').next().unwrap().parse().unwrap(); + let profile = file.xref().get::>(craft_pdf::syntax::object::ObjectIdentifier::new(stream, 0)).unwrap(); + Some(profile.decoded().unwrap().into_owned()) + }) + .collect() +} + +/// Transparency in a tagged CMYK document blends in the document's CMYK profile space, not in the +/// space of the profile a CMYK image carries, whichever the file holds first. +#[test] +fn tagged_cmyk_documents_blend_in_their_own_profile_beside_an_image_profile() { + let icc = cmyk_profile(|_| {}); + let document = cms::icc_bytes(&cms::active_settings().cmyk).unwrap().to_vec(); + // A transparent CMYK rectangle over the image, and the image itself transparent. + let mut d = image_doc(cmyk_tiff_with([0, 102, 255, 0], 4, 4, &icc), 4, 4); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), + Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + n.opacity = 0.5; + let l = d.layers[0].id; + d.insert(Some(l), 1, n).unwrap(); + let mut alone = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); + alone.images.insert("img".into(), cmyk_tiff_with([0, 102, 255, 0], 4, 4, &icc)); + let image = + ImageObject { key: "img".into(), width: 4, height: 4, xf: Affine::scale(10.0), link: None, placement: Default::default(), pixels: None }; + let mut n = Node::new(alone.alloc_id(), NodeKind::Image(Box::new(image))); + n.opacity = 0.5; + let l = alone.layers[0].id; + alone.insert(Some(l), 0, n).unwrap(); + for d in [d, alone] { + let out = export_with_report(&d, &opts(json!({"output": {"profiles": "all"}}))).unwrap(); + let w = written(&out.bytes); + assert!(w.iter().any(|w| w.icc.as_deref() == Some(icc.as_slice())), "the image's own profile: {w:?}"); + for profile in group_profiles(&out.bytes) { + assert!(profile.as_ref().is_none_or(|p| *p == document), "the group blends in the document's CMYK profile or DeviceCMYK"); + } + } +} + +/// Only a well-formed CMYK profile tags an image: one whose size, signature, class, colour space +/// or tag table is wrong leaves it untagged. +#[test] +fn a_cmyk_image_with_a_malformed_profile_is_written_untagged() { + let tag = |icc: Vec| crate::images::cmyk_icc(&ImageBlob::new("image/jpeg", cmyk_jpeg_with(&flat([0, 102, 255, 0], 4), 2, 2, &icc))); + let good = cmyk_profile(|_| {}); + assert_eq!(tag(good.clone()), Some(good)); + let bad: [(&str, Vec); 7] = [ + ("declared size", cmyk_profile(|i| i[0..4].copy_from_slice(&200u32.to_be_bytes()))), + ("signature", cmyk_profile(|i| i[36..40].copy_from_slice(b"xxxx"))), + ("device link class", cmyk_profile(|i| i[12..16].copy_from_slice(b"link"))), + ("RGB colour space", cmyk_profile(|i| i[16..20].copy_from_slice(b"RGB "))), + ("tag beyond the end", cmyk_profile(|i| i[136..140].copy_from_slice(&150u32.to_be_bytes()))), + ("table beyond the end", cmyk_profile(|i| i[128..132].copy_from_slice(&3u32.to_be_bytes()))), + ("header only", cmyk_profile(|i| i.truncate(100))), + ]; + for (what, icc) in bad { + assert_eq!(tag(icc), None, "{what}"); + } + // And the image is written untagged. + let icc = cmyk_profile(|i| i[36..40].copy_from_slice(b"xxxx")); + let d = image_doc(cmyk_tiff_with([0, 102, 255, 0], 4, 4, &icc), 4, 4); + let out = export_with_report(&d, &opts(json!({"output": {"profiles": "taggedSource"}}))).unwrap(); + let w = written(&out.bytes); + assert_eq!((w.len(), w[0].space.as_str()), (1, "DeviceCMYK"), "{w:?}"); +} + /// A Press Quality export of a 2 × 2 CMYK image masked by PNG `mask` on its own pixel grid /// (`clip`, `invert`): the file, and the decoded soft mask of each CMYK image (`None`: it has none). fn export_masked(mask: Vec, clip: bool, invert: bool) -> (Vec, Vec>>) { diff --git a/crates/pdf/src/tests/output.rs b/crates/pdf/src/tests/output.rs index a15c2aa429..e0f00dc0e7 100644 --- a/crates/pdf/src/tests/output.rs +++ b/crates/pdf/src/tests/output.rs @@ -161,6 +161,36 @@ fn spot_alternates_and_device_n_gradient_inks_are_written_as_the_document_holds_ assert!(text.contains("/C0[0 0 0.75]/C1[0.75 0.04 0]"), "the stops' C75, K4 and 75% Gold are written as they are"); } +#[test] +fn blend_steps_between_spot_gradients_with_process_plates_print_both_inks() { + let stop = |offset: f32, tint: f32, plates: Option<[f32; 4]>| { + let mut s = GradientStop::new(offset, Color::cmyk(0.0, 0.2, 0.8, 0.1).tinted(tint)); + s.swatch = Some("Gold".into()); + s.tint = tint; + s.plates = plates; + s + }; + let gradient = |tint: f32, plates: Option<[f32; 4]>| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![stop(0.0, tint, plates), stop(1.0, tint, plates)])))) + }; + let step_pdf = |a: &Paint, b: &Paint| { + let mut d = doc(ColorMode::Cmyk, &[]); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true }); + let step = vectorcraft_doc::blend::lerp_paint(a, b, 0.5); + let l = d.layers[0].id; + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 30.0)), Appearance::basic(step, Paint::None, 0.0)); + d.insert(Some(l), 0, n).unwrap(); + String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned() + }; + // The middle step prints 60% Gold over 30% Cyan, in one DeviceN shading of both inks. + let text = step_pdf(&gradient(0.2, Some([0.2, 0.0, 0.0, 0.0])), &gradient(1.0, Some([0.4, 0.0, 0.0, 0.0]))); + assert!(text.contains("[/DeviceN[/Cyan/Gold]"), "{text}"); + assert!(text.contains("/C0[0.3 0.6]/C1[0.3 0.6]"), "{text}"); + // Without plates the step is a Separation shading of Gold alone. + let text = step_pdf(&gradient(0.2, None), &gradient(1.0, None)); + assert!(!text.contains("/DeviceN") && text.contains("[/Separation/Gold/"), "{text}"); +} + /// A spot swatch's Separation space with alternate C0 M20 Y80 K10 under `colorant` (a PDF name). fn gold_separation(colorant: &str) -> String { format!("[/Separation{colorant}/DeviceCMYK<>]") diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 5c5e1eb2c0..155912c124 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::json; -use vectorcraft_color::{Color, Paint, Swatch}; +use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch}; use vectorcraft_doc::{Appearance, Artboard, Document, LayerColor, Node, NodeKind}; use vectorcraft_geom::{Rect, shapes}; use vectorcraft_testkit::raster::render_region; @@ -536,6 +536,70 @@ fn cmyk_and_a_spot_separate_into_five_plates() { assert_eq!(inks(&p[2]), [0.0, 40.0], "the gold's yellow"); } +/// A 100 pt CMYK document with spot swatch `Gold` and a square filled with gradient `g`. +fn gold_gradient_doc(g: GradientPaint) -> Document { + let mut d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 50.0, 50.0), Paint::Gradient(Box::new(g)))]); + d.color_mode = vectorcraft_doc::ColorMode::Cmyk; + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true }); + d +} + +/// The grey of each plate of a separation of `d` (job options `v`), in percent: of the first +/// gradient stop, and of the first freeform point when there is one. No plate keeps a link or +/// process plates. +fn gradient_plates(d: &Document, v: serde_json::Value) -> Vec<(f32, Option)> { + let opts = PrintOptions { settings: settings(v), ..Default::default() }; + let gray = |c: &Color| match c { + Color::Gray { k } => (k * 100.0).round(), + c => panic!("{c:?}"), + }; + let plan = plan(d, &opts).unwrap(); + plan.plates + .iter() + .map(|p| { + let mut found = vec![]; + p.walk(|n| { + if let Some(Paint::Gradient(g)) = n.appearance.fill().map(|f| &f.paint) { + let s = &g.gradient.stops[0]; + assert_eq!((s.swatch.as_deref(), s.tint, s.plates), (None, 1.0, None), "the stop is unlinked"); + found.push((gray(&s.color), g.freeform.as_ref().and_then(|f| f.points.first()).map(|q| gray(&q.color)))); + } + }); + assert_eq!(found.len(), 1); + found[0] + }) + .collect() +} + +#[test] +fn a_spot_gradient_stop_prints_its_process_plates() { + let mut mixed = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05)); + (mixed.swatch, mixed.tint, mixed.plates) = (Some("Gold".into()), 0.5, Some([0.25, 0.0, 0.4, 0.0])); + let g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![mixed, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))])); + let d = gold_gradient_doc(g); + let first = |v| gradient_plates(&d, v).into_iter().map(|(s, _)| s).collect::>(); + assert_eq!(first(json!({"output": {"mode": "separations"}})), [25.0, 0.0, 40.0, 0.0, 50.0], "Cyan, Magenta, Yellow, Black, Gold"); + // Spots as process: the plates layered with the gold tint's process inks (0, 10, 40, 5). + assert_eq!(first(json!({"output": {"mode": "separations", "spotsToProcess": true}})), [25.0, 10.0, 64.0, 5.0]); +} + +#[test] +fn a_freeform_gradients_points_separate_into_plates() { + let mut g = GradientPaint::new(Gradient::new(GradientKind::Freeform, vec![GradientStop::new(0.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))])); + let point = FreeformPoint::new(vectorcraft_geom::Point::new(25.0, 25.0), Color::cmyk(0.0, 0.6, 0.0, 0.0)); + g.freeform = Some(Freeform { points: vec![point], ..Default::default() }); + let d = gold_gradient_doc(g); + let set = json!({"output": {"mode": "separations"}}); + let points = gradient_plates(&d, set.clone()).into_iter().map(|(_, p)| p).collect::>(); + assert_eq!(points, [Some(0.0), Some(60.0), Some(0.0), Some(0.0), Some(0.0)], "the point's magenta, on its plate only"); + // Each plate draws the square in the grey of the point's ink on it. + let plan = plan(&d, &PrintOptions { settings: settings(set), ..Default::default() }).unwrap(); + let drawn: Vec = plan.plates.iter().map(|p| render_region(p, Rect::new(0.0, 0.0, 50.0, 50.0), 1.0).over_white(25, 25)[0]).collect(); + for (plate, (v, ink)) in drawn.iter().zip([0.0, 0.6, 0.0, 0.0, 0.0]).enumerate() { + assert!((f32::from(*v) - 255.0 * (1.0 - ink)).abs() <= 3.0, "plate {plate}: {drawn:?}"); + } +} + #[test] fn trim_marks_surround_the_artboard_on_the_paper() { let d = doc(200.0, 100.0, &[(Rect::new(0.0, 0.0, 200.0, 100.0), rgb(1.0, 0.0, 0.0))]); diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 2d67db2073..7bcc117a23 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -239,19 +239,29 @@ fn inks(c: &Color) -> [f32; 4] { let key = c.key(); let cms = cms::active(); SEPARATED.with(|s| { - let (owner, seen) = &mut *s.borrow_mut(); + let kept = &mut *s.borrow_mut(); + let i = match kept.iter().position(|(owner, _)| Arc::ptr_eq(owner, &cms)) { + Some(i) => i, + None => { + if kept.len() >= KEPT_TABLES { + kept.remove(0); + } + kept.push((cms.clone(), HashMap::new())); + kept.len() - 1 + } + }; + let seen = &mut kept[i].1; // Large enough for the many colours of gradient meshes and blends, which otherwise cleared // it over and over and separated every colour through the profile again. - if !owner.as_ref().is_some_and(|o| Arc::ptr_eq(o, &cms)) || seen.len() >= 1 << 16 { + if seen.len() >= 1 << 16 { seen.clear(); - *owner = Some(cms.clone()); } *seen.entry(key).or_insert_with(|| cms.to_cmyk(c, cms.settings().intent)) }) } -/// Separated colours, for the colour settings they were separated with. -type Separated = (Option>, HashMap<[u32; 5], [f32; 4]>); +/// Separated colours, for each of the colour settings they were separated with, the newest last. +type Separated = Vec<(Arc, HashMap<[u32; 5], [f32; 4]>)>; thread_local! { static SEPARATED: RefCell = RefCell::default(); @@ -276,31 +286,37 @@ impl InkLut { } } -/// A table built for the colour settings it was built with, and its generation (see [`cached`]). -type Cached = Mutex, Arc, u64)>>; +/// Tables built for the colour settings they were built with, and their generations, the newest +/// last (see [`cached`]). +type Cached = Mutex, Arc, u64)>>; + +/// How many tables each [`Cached`] keeps: documents drawn side by side through their own profiles +/// don't make them again and again. +const KEPT_TABLES: usize = 4; /// The last table generation handed out: images made with a table are cached under its /// generation (see [`crate::images`]), and none is ever reused. static GENERATION: AtomicU64 = AtomicU64::new(0); -static DISPLAY: Cached = Mutex::new(None); +static DISPLAY: Cached = Mutex::new(Vec::new()); /// Screen colours → [`Ink::Cmy`] and [`Ink::K`] values (placed images). -static SEPARATE: Cached<[ProofLut; 2]> = Mutex::new(None); +static SEPARATE: Cached<[ProofLut; 2]> = Mutex::new(Vec::new()); -/// The table in `slot` for the active colour settings, built by `build` when they changed, and -/// its generation. +/// The table in `slot` for the active colour settings, built by `build` when none is kept for +/// them, and its generation. fn cached(slot: &Cached, build: impl FnOnce(&Cms) -> T) -> (Arc, u64) { let cms = cms::active(); let mut s = slot.lock().unwrap_or_else(|e| e.into_inner()); - match &*s { - Some((owner, t, g)) if Arc::ptr_eq(owner, &cms) => (t.clone(), *g), - _ => { - let t = Arc::new(build(&cms)); - let g = GENERATION.fetch_add(1, Ordering::Relaxed) + 1; - *s = Some((cms, t.clone(), g)); - (t, g) - } + if let Some((_, t, g)) = s.iter().find(|(owner, _, _)| Arc::ptr_eq(owner, &cms)) { + return (t.clone(), *g); } + let t = Arc::new(build(&cms)); + let g = GENERATION.fetch_add(1, Ordering::Relaxed) + 1; + if s.len() >= KEPT_TABLES { + s.remove(0); + } + s.push((cms, t.clone(), g)); + (t, g) } /// [`compose`] puts at least this many pixels in each band but the last. @@ -585,6 +601,18 @@ mod tests { assert_eq!(pm.data()[0].r, 204, "source pixels remain unchanged"); } + #[test] + fn tables_of_two_color_settings_are_kept_side_by_side() { + // Two documents drawn in turn through their own settings each keep their tables. + let settings = || Arc::new(Cms::new(&cms::ColorSettings::default()).unwrap()); + let (a, b) = (settings(), settings()); + let tables = |c: &Arc| cms::with_active(c.clone(), || (display_lut().1, separation().1)); + let (first_a, first_b) = (tables(&a), tables(&b)); + assert_ne!(first_a, first_b, "each settings get their own tables"); + assert_eq!(tables(&a), first_a, "the first settings' tables are not made again"); + assert_eq!(tables(&b), first_b, "the second settings' tables are not made again"); + } + #[test] fn the_ink_table_matches_the_profile() { let cms = cms::active(); diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index a6736b022d..284ad8ce5d 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -4,7 +4,8 @@ //! space (CMYK press or RGB/colour-blindness simulation) → display, through a cached 17³ LUT. //! * **Separations Preview** recolours the *document* before rendering: each paint colour is split //! into process inks (colour-managed), or a spot ink at the colour's tint when it's linked to a -//! spot swatch (solid colours and gradient stops alike). With one plate visible the plate +//! spot swatch (solid colours and gradient stops alike), with the process plates a gradient stop +//! prints beside its spot ink ([`vectorcraft_color::GradientStop::plates`]). With one plate visible the plate //! renders as greyscale ink coverage (black = 100%); with several, the visible inks are //! composited through the proof profile, spots multiplied on top. Colours linked to the //! Registration swatch print their tint on every plate, spot plates included. @@ -29,6 +30,7 @@ use vectorcraft_color::cms::{self, Cms, CmykLut, PROCESS_PLATES, ProofLut, lab}; pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget}; use vectorcraft_color::{Color, Swatch, SwatchGroup}; pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color}; +use vectorcraft_doc::inks::{StopPlates, map_node_inks, stop_inks}; use vectorcraft_doc::overprint::darken_overprints; use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, Shared}; use vello_cpu::Pixmap; @@ -59,8 +61,8 @@ fn plate_rgb(c: &Cms, proof: &ProofSetup, visible: &[String], ink: &Inks) -> [f3 c.proof_cmyk_to_srgb(cmyk, proof) } -fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, link: Link) -> Color { - let ink = inks(doc, c, color, link, proof.intent); +fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, link: Link, plates: StopPlates) -> Color { + let ink = stop_inks(doc, c, color, link, plates, proof.intent); let mut rgb = plate_rgb(c, proof, visible, &ink); // Over several plates the visible spot inks print on top. for name in visible.iter().filter(|v| visible.len() != 1 && !PROCESS_PLATES.contains(&v.as_str())) { @@ -355,7 +357,7 @@ impl How<'_> { fn apply(&self, a: &mut Arc) { darken_overprints(a, self.discard_white); if let Some((c, proof, visible)) = self.plates { - map_node_colors(Arc::make_mut(a), &mut |col, sw| plate_color(self.doc, c, proof, visible, col, sw)); + map_node_inks(Arc::make_mut(a), &mut |col, sw, plates| plate_color(self.doc, c, proof, visible, col, sw, plates)); } } } diff --git a/crates/render/src/tests/colorize.rs b/crates/render/src/tests/colorize.rs index 721707502d..8a8891c7da 100644 --- a/crates/render/src/tests/colorize.rs +++ b/crates/render/src/tests/colorize.rs @@ -289,6 +289,35 @@ fn separations_preview_keeps_a_reduced_dcs_plates_coverage() { assert!(p[..3].iter().all(|v| (100..=160).contains(v)), "half the ink, got {p:?}"); } +/// A gradient stop linked to a spot ink that prints process plates with it shows them on those +/// plates, and its tint on the spot plate. +#[test] +fn separations_preview_shows_a_spot_gradient_stops_process_plates() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let mut d = Document::new_with_mode(20.0, 20.0, vectorcraft_doc::ColorMode::Cmyk); + d.swatches.push(vectorcraft_color::Swatch { + name: "Gold".into(), + paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), + global: true, + spot: true, + }); + let stop = |offset| { + let mut s = GradientStop::new(offset, Color::cmyk(0.25, 0.1, 0.8, 0.05)); + (s.swatch, s.tint, s.plates) = (Some("Gold".into()), 0.5, Some([0.25, 0.0, 0.4, 0.0])); + s + }; + let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![stop(0.0), stop(1.0)])))); + let (layer, id) = (d.layers[0].id, d.alloc_id()); + let square = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(g, Paint::None, 0.0)); + d.insert(Some(layer), 0, square).unwrap(); + let gray = |plate: &str| Renderer::new().render(&d, 20, 20, Affine::IDENTITY, &separations(&[plate])).pixel(10, 10)[0]; + let near = |v: u8, ink: f32| (f32::from(v) - 255.0 * (1.0 - ink)).abs() <= 3.0; + for (plate, ink) in [("Cyan", 0.25), ("Magenta", 0.0), ("Yellow", 0.4), ("Black", 0.0), ("Gold", 0.5)] { + let v = gray(plate); + assert!(near(v, ink), "{plate}: {ink} of the ink, got {v}"); + } +} + /// Separations Preview showing `plates`, as the canvas draws it. fn separations(plates: &[&str]) -> RenderOptions { let proof = proof::ProofSetup { separations: Some(plates.iter().map(|p| p.to_string()).collect()), ..Default::default() }; diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 8716af7367..ea1d5ab95b 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -431,11 +431,75 @@ impl Writer<'_> { self.def(1, ""); self.line(&format!("")); self.depth += 1; + self.spanning_rect(&bare, ®ion); self.node_body(&bare); self.depth -= 1; self.line(""); } + /// Some readers draw a masked group only inside the bounding box of its shapes, which leaves + /// out a drop shadow, blur, glow or feather that paints past them. When `n`, written inside a + /// ``, holds one ([`Self::holds_raster`]), a hidden, unpainted rectangle over the + /// mask's `region` makes that box cover what it paints. + fn spanning_rect(&mut self, n: &Node, region: &str) { + if self.holds_raster(n) { + self.line(&format!("")); + } + } + + /// Do `n`, its fills and strokes, its members at any depth, its symbols' art or the art its + /// live objects are written as have a visible raster effect (shadow, glow, blur, feather)? + /// Objects that aren't written (hidden ones, unless the options keep them) are skipped, and so + /// are fills and strokes that [`Self::shape`] doesn't write (hidden, unpainted or zero-width). + /// An instance with overrides is looked at in the art it is written as + /// ([`vectorcraft_brush::instance_art`]). The walk keeps its own stack, and looks at each + /// symbol's art once, and at each instance's overridden art once. + fn holds_raster(&self, n: &Node) -> bool { + let mut stack: Vec> = vec![]; + let mut symbols: std::collections::HashSet<(String, Option)> = std::collections::HashSet::new(); + let paints = |i: &AppearanceItem| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, + }; + // Whether `n` itself has one; else its members, symbol art or expanded art go on the stack. + let mut visit = |n: &Node, stack: &mut Vec>| { + if !(n.visible || self.opts.hidden_layers) { + return false; + } + if has_raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| paints(i) && has_raster(i.effects())) { + return true; + } + // A live object is written as its expanded art ([`Self::node_body`]), which holds no + // live objects. + if vectorcraft_doc::live::is_live(n) { + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + if !vectorcraft_doc::live::is_live(&g) { + stack.push(std::sync::Arc::new(g)); + return false; + } + } + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + let custom = !n.extras().symbol_overrides.is_empty(); + if symbols.insert((symbol.clone(), custom.then_some(n.id))) + && let Some(s) = self.doc.symbols.iter().find(|s| s.name == *symbol) + { + stack.push(if custom { std::sync::Arc::new(vectorcraft_brush::instance_art(&s.art, n)) } else { s.art.clone() }); + } + } + stack.extend(n.children().into_iter().flatten().cloned()); + false + }; + if visit(n, &mut stack) { + return true; + } + while let Some(c) = stack.pop() { + if visit(&c, &mut stack) { + return true; + } + } + false + } + /// What `write` writes to the body, taken out of it (written at `depth`, for ``). fn detached(&mut self, depth: usize, write: impl FnOnce(&mut Self)) -> String { let (body, saved) = (std::mem::take(&mut self.body), self.depth); @@ -664,7 +728,7 @@ impl Writer<'_> { } } self.symbol_nodes = after; - for (above, (_, body)) in above.iter().zip(written) { + for ((c, above), (_, body)) in elements.iter().zip(&above).zip(written) { if above.is_empty() { self.body.push_str(&body); continue; @@ -672,6 +736,11 @@ impl Writer<'_> { let ids: Vec<&str> = above.iter().filter_map(|&j| shapes[j].as_deref()).collect(); let mid = self.knockout_mask(&ids, ®ion); self.line(&format!("")); + // An element that paints past its shapes starts its mask group with a spanning + // rectangle (`Self::spanning_rect`). + self.depth += 1; + self.spanning_rect(c, ®ion); + self.depth -= 1; self.body.push_str(&body); self.line(""); } diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index bab4246270..d2b5504f9d 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -4,6 +4,10 @@ //! (missing, remote, not an image) becomes a placeholder that keeps the link and its place, so it //! can be relinked, and a warning. //! +//! A linked SVG file's own `` links are read the same way, relative paths from that file's +//! folder, within the same limits; an href that leads back to an SVG file showing it gets the +//! placeholder and a warning. +//! //! A raster image, linked or a data URL, whose orientation (EXIF, or a TIFF's Orientation tag) //! turns or flips its pixels is turned before usvg gets it, so it is sized, fitted and stored as //! it is shown. @@ -35,6 +39,9 @@ pub const MAX_NESTED_SVG_TEXT: u64 = 512 << 20; pub(in crate::import) struct Files { /// What usvg gets for each linked href. sources: HashMap, + /// The SVG images usvg is reading, innermost last: a linked SVG file, whose own links an href + /// inside it is looked up in, or `None` for a data URL. + within: Mutex>>>, /// The link of each linked raster or placeholder, by the address of the bytes usvg got. links: HashMap, /// An SVG image had text (which isn't imported). @@ -61,6 +68,9 @@ pub(in crate::import) struct Files { svg_text: AtomicU64, /// SVG images were left out for exceeding [`MAX_NESTED_SVG_READS`] or [`MAX_NESTED_SVG_TEXT`]. too_much_svg_read: AtomicBool, + /// Images linked from linked SVG files were left out, their files not read, past the same + /// limits for the files read for links. + too_much_linked: AtomicBool, /// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got /// (kept here too, so the address stays its own). cmyk: Mutex>, @@ -84,8 +94,82 @@ type RasterKind = fn(Arc>) -> ImageKind; enum Source { /// A raster file of the kind usvg reads it as. Raster(RasterKind, Arc>), - /// An SVG file's text (unpacked when it is SVGZ), parsed by usvg as an image. - Svg(Arc), + /// An SVG file, parsed by usvg as an image. + Svg(Arc), +} + +/// A linked SVG file as usvg reads it. +struct SvgFile { + /// Its text (unpacked when it is SVGZ), placeholders stretched to their boxes. + text: Arc, + /// What usvg gets for each href its own `` elements link to. + sources: HashMap, +} + +/// The state of [`Files::read`] across the SVG files it reads the links of. +struct Reading<'a, 'w> { + opts: &'a ImportOptions<'a>, + warnings: &'w mut Vec, + /// The placeholder's bytes, made once. + placeholder: Option>>, + /// What each file read gave, by the file: hrefs spelled differently (`a.png`, `./a.png`, + /// `%61.png`), and in different SVG files, share one read and one copy of its bytes. Only an + /// SVG file whose art depends on where it is shown ([`Reading::placed`]) isn't kept, and a + /// raster read past the limits, which is left out. + by_file: HashMap, + /// The SVG files whose links are being read, innermost last, by [`identity`]. + active: Vec, + /// What the file being read gives depends on where it is shown: a link inside it led back to + /// an SVG file around it, or an SVG file inside it nests too deep there for its links to be + /// read. + placed: bool, + /// How many linked SVG files were read for their links, and the bytes of every linked SVG + /// file read and of every file read for the links of a linked SVG file. Rasters the imported + /// SVG links to aren't counted. + svg_reads: usize, + svg_text: u64, +} + +impl Reading<'_, '_> { + /// The files read for links passed [`MAX_NESTED_SVG_READS`] or [`MAX_NESTED_SVG_TEXT`]. + fn spent(&self) -> bool { + self.svg_reads > MAX_NESTED_SVG_READS || self.svg_text > MAX_NESTED_SVG_TEXT + } +} + +/// What reading a linked file gave: what usvg gets, or why it can't be read. +#[derive(Clone)] +struct Outcome { + source: Result, + /// An SVG file whose own links weren't read, past [`Reading::spent`]: inside a linked SVG + /// file it is left out, and where the imported SVG links to it, it is shown without them. + capped: bool, +} + +impl Outcome { + fn not_read(why: &'static str) -> Self { + Outcome { source: Err(why), capped: false } + } + + fn shown(source: Source) -> Self { + Outcome { source: Ok(source), capped: false } + } +} + +/// Does SVG text `text` have an `` that links to a file (not a data URL)? +fn links_files(text: &str) -> bool { + super::parse(text).is_ok_and(|xml| xml.descendants().any(|n| n.is_element() && n.tag_name().name() == "image" && file_href(n).is_some())) +} + +/// The href of `` element `n`, unless it is a data URL. +fn file_href<'a>(n: roxmltree::Node<'a, '_>) -> Option<&'a str> { + href(n).filter(|h| !h.trim_start().starts_with("data:")) +} + +/// What the link to an SVG file being read is compared by, to find a link back to it: its path +/// with links and `..` resolved, or the path as given when that can't be found. +fn identity(file: &str) -> String { + std::fs::canonicalize(file).map_or_else(|_| file.to_string(), |p| p.to_string_lossy().into_owned()) } /// A linked raster image (or the placeholder of a missing one). @@ -164,54 +248,156 @@ impl Files { /// relinked. pub(in crate::import) fn read(svg: &str, xml: &roxmltree::Document, opts: &ImportOptions, edits: &mut Edits, warnings: &mut Vec) -> Self { let mut files = Files::default(); - let mut placeholder = None; - // What each file read gave, by the file: hrefs spelled differently (`a.png`, `./a.png`, - // `%61.png`) share one read and one copy of its bytes. - let mut by_file: HashMap> = HashMap::new(); - for n in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "image") { - let Some(h) = href(n).filter(|h| !h.trim_start().starts_with("data:")) else { continue }; - if !files.sources.contains_key(h) { - let (path, file) = target(h, opts.folder); - // A failed read is cached too, but each href gets its own placeholder: its own link - // path (an Arc shared between hrefs would share the first href's link) and warning. - let cached = file.as_ref().and_then(|f| by_file.get(f)).cloned(); - let source = match cached { - Some(source) => source, - None => { - let read = file.clone().and_then(|file| opts.read.and_then(|read| read(&file))); - let source = match read { - Some((bytes, link)) => files.source(bytes, link).ok_or("is not an image VectorCraft reads"), - None => Err("not found"), - }; - if let Some(file) = file { - by_file.insert(file, source.clone()); - } - source - } - }; - let source = source.or_else(|why| { - warnings.push(format!("linked image '{h}' {why}: a placeholder keeps its place until it is relinked")); - let bytes = Arc::new(placeholder.get_or_insert_with(missing_placeholder).clone().ok_or(why)?); - files.links.insert(key(&bytes), Linked { link: LinkInfo::new(path), missing: true }); - Ok::<_, &str>(Source::Raster(ImageKind::PNG, bytes)) - }); - let Ok(source) = source else { continue }; - files.sources.insert(h.to_string(), source); - } - if files.placeholder(h) { - edits.set(svg, n, "preserveAspectRatio", "none"); - } - } + let mut reading = + Reading { opts, warnings, placeholder: None, by_file: HashMap::new(), active: vec![], placed: false, svg_reads: 0, svg_text: 0 }; + files.sources = files.links_of(svg, xml, opts.folder, None, edits, &mut reading); files } + /// What usvg gets for each href the `` elements of `xml` (the text `svg`) link to, + /// relative paths taken from `folder`. `inside` is how deep the SVG images `xml` shows nest in + /// it and the SVGs around it, measured when an SVG file is read (`None` for `xml` itself). + fn links_of( + &mut self, + svg: &str, + xml: &roxmltree::Document, + folder: Option<&str>, + mut inside: Option, + edits: &mut Edits, + reading: &mut Reading<'_, '_>, + ) -> HashMap { + let mut sources: HashMap = HashMap::new(); + let nested = !reading.active.is_empty(); + for n in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "image") { + let Some(h) = file_href(n) else { continue }; + if !sources.contains_key(h) { + let (path, file) = target(h, folder); + // A failed read is cached too, but each href gets its own placeholder: its own link + // path (an Arc shared between hrefs would share the first href's link) and warning. + let back = nested && file.as_deref().is_some_and(|f| reading.active.contains(&identity(f))); + let cached = file.as_ref().and_then(|f| reading.by_file.get(f)).cloned(); + let outcome = match cached { + _ if back => { + reading.placed = true; + Some(Outcome::not_read("leads back to an SVG file that shows it")) + } + Some(outcome) => Some(outcome), + None => { + let around = std::mem::replace(&mut reading.placed, false); + let outcome = self.file(file.as_deref(), xml, &mut inside, reading); + // Kept unless what it shows depends on where it is. + let placed = reading.placed; + reading.placed |= around; + if let (Some(file), Some(outcome)) = (file, &outcome) + && !placed + { + reading.by_file.insert(file, outcome.clone()); + } + outcome + } + }; + // Inside a linked SVG file, past what may be read, the image is left out (noted). + let source = match outcome { + None => continue, + Some(Outcome { capped: true, .. }) if nested => { + self.too_much_svg_read.store(true, Ordering::Relaxed); + continue; + } + Some(outcome) => outcome.source, + }; + let source = source.or_else(|why| { + reading.warnings.push(format!("linked image '{h}' {why}: a placeholder keeps its place until it is relinked")); + let bytes = Arc::new(reading.placeholder.get_or_insert_with(missing_placeholder).clone().ok_or(why)?); + self.links.insert(key(&bytes), Linked { link: LinkInfo::new(path), missing: true }); + Ok::<_, &str>(Source::Raster(ImageKind::PNG, bytes)) + }); + let Ok(source) = source else { continue }; + sources.insert(h.to_string(), source); + } + if sources.get(h).is_some_and(|s| self.missing(s)) { + edits.set(svg, n, "preserveAspectRatio", "none"); + } + } + sources + } + + /// What reading linked file `file` gives, linked from `xml`, whose SVG images nest `inside` + /// deep (measured here when an SVG file is read); `None` for an image left out, noted. Inside + /// a linked SVG file each file read counts toward [`MAX_NESTED_SVG_TEXT`] before it is looked + /// at, and once the files read for links pass [`MAX_NESTED_SVG_READS`] or + /// [`MAX_NESTED_SVG_TEXT`] no file is read there; a raster whose read passes them is left out + /// undecoded. Every file the imported SVG links to is read. + fn file(&mut self, file: Option<&str>, xml: &roxmltree::Document, inside: &mut Option, reading: &mut Reading<'_, '_>) -> Option { + let (Some(file), Some(read)) = (file, reading.opts.read) else { return Some(Outcome::not_read("not found")) }; + let nested = !reading.active.is_empty(); + if nested && reading.spent() { + self.too_much_linked.store(true, Ordering::Relaxed); + return None; + } + let Some((bytes, link)) = read(file) else { return Some(Outcome::not_read("not found")) }; + let counted = if nested { bytes.len() as u64 } else { 0 }; + reading.svg_text = reading.svg_text.saturating_add(counted); + if nested && reading.spent() && image::guess_format(&bytes).is_ok() { + self.too_much_linked.store(true, Ordering::Relaxed); + return None; + } + match self.source(bytes, link) { + Some(Source::Svg(svg)) => { + let inside = *inside.get_or_insert_with(|| depth::height(xml, MAX_ELEMENT_DEPTH).unwrap_or(MAX_ELEMENT_DEPTH)); + Some(self.svg_file(svg, file, inside, counted, reading)) + } + source => Some(source.map_or_else(|| Outcome::not_read("is not an image VectorCraft reads"), Outcome::shown)), + } + } + + /// Linked SVG file `file`, read as `svg`, with the files its own `` elements link to + /// read from its folder; `inside` is how deep it nests in the SVGs around it, and `counted` the + /// bytes of it already counted toward [`MAX_NESTED_SVG_TEXT`]. Its text counts toward + /// [`MAX_NESTED_SVG_TEXT`], and reading its links toward [`MAX_NESTED_SVG_READS`]; once they + /// are passed, its links aren't read ([`Outcome::capped`], noted when it links to files). Its + /// links aren't read either when it nests too deep to be shown, or when it isn't XML. + fn svg_file(&mut self, svg: Arc, file: &str, inside: usize, counted: u64, reading: &mut Reading<'_, '_>) -> Outcome { + reading.svg_text = reading.svg_text.saturating_add((svg.text.len() as u64).saturating_sub(counted)); + let links = svg.text.contains("image"); + reading.svg_reads += usize::from(links); + if reading.spent() { + if links && links_files(&svg.text) { + self.too_much_linked.store(true, Ordering::Relaxed); + } + return Outcome { source: Ok(Source::Svg(svg)), capped: true }; + } + let left = MAX_ELEMENT_DEPTH.saturating_sub(inside); + if !links { + return Outcome::shown(Source::Svg(svg)); + } + if depth::xml_deeper_than(&svg.text, left) { + reading.placed = true; + return Outcome::shown(Source::Svg(svg)); + } + // Read from its own handle: the text stays borrowed while `svg` may be given back. + let text = svg.text.clone(); + let Ok(xml) = super::parse(&text) else { return Outcome::shown(Source::Svg(svg)) }; + let Some(height) = depth::height(&xml, left) else { + reading.placed = true; + return Outcome::shown(Source::Svg(svg)); + }; + let folder = Path::new(file).parent().map(|p| p.to_string_lossy().into_owned()); + let mut edits = Edits::default(); + reading.active.push(identity(file)); + let sources = self.links_of(&text, &xml, folder.as_deref(), Some(inside + height), &mut edits, reading); + reading.active.pop(); + let text = Arc::from(edits.apply(&text).as_ref()); + Outcome::shown(Source::Svg(Arc::new(SvgFile { text, sources }))) + } + /// What usvg gets for a file read (`bytes`, linked by `link`): a raster as [`shown`] gives it - /// (formats usvg doesn't read are stored as PNG) or an SVG; `None` for anything else. + /// (formats usvg doesn't read are stored as PNG) or an SVG, its own links not read yet; `None` + /// for anything else. fn source(&mut self, bytes: Vec, link: LinkInfo) -> Option { let Ok(format) = image::guess_format(&bytes) else { // Unpacked once, here, within what the SVGZ images may unpack to. let text = crate::text_within(&bytes, &self.unpacked).ok()?; - return text.contains(" bool { - matches!(self.sources.get(h), Some(Source::Raster(_, b)) if self.links.get(&key(b)).is_some_and(|l| l.missing)) + /// Is `source` a placeholder? + fn missing(&self, source: &Source) -> bool { + matches!(source, Source::Raster(_, b) if self.links.get(&key(b)).is_some_and(|l| l.missing)) + } + + /// What usvg gets for href `h` where it is read: inside a linked SVG file, from that file's + /// own links; outside any SVG image, from the imported SVG's. Inside an SVG data URL, which + /// has no folder of its own, nothing. + fn find(&self, h: &str) -> Option { + let within = self.within.lock().ok()?; + let sources = match within.last() { + None => &self.sources, + Some(file) => &file.as_ref()?.sources, + }; + sources.get(h).or_else(|| sources.get(h.trim())).cloned() } /// The link of the image whose data usvg got as `kind`. @@ -272,6 +470,12 @@ impl Files { MAX_NESTED_SVG_TEXT >> 20 )); } + if self.too_much_linked.load(Ordering::Relaxed) { + notes.push(format!( + "images linked from linked SVG files were left out: once {MAX_NESTED_SVG_READS} SVG files or {} MB have been read for their links, the files they link to aren't read", + MAX_NESTED_SVG_TEXT >> 20 + )); + } notes } @@ -308,9 +512,9 @@ impl Files { let (kind, bytes) = self.keep(shown(bytes, format)?); Some(kind(bytes)) }), - resolve_string: Box::new(move |h, opts| match self.sources.get(h).or_else(|| self.sources.get(h.trim()))? { - Source::Raster(kind, bytes) => Some(kind(bytes.clone())), - Source::Svg(text) => self.svg(text, opts), + resolve_string: Box::new(move |h, opts| match self.find(h)? { + Source::Raster(kind, bytes) => Some(kind(bytes)), + Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts), }), } } @@ -320,7 +524,7 @@ impl Files { /// noted. fn svg_data(&self, bytes: &[u8], opts: &usvg::Options) -> Option { match crate::text_within(bytes, &self.unpacked) { - Ok(text) => self.svg(&text, opts), + Ok(text) => self.svg(&text, None, opts), Err(e) => leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))), } } @@ -329,8 +533,9 @@ impl Files { /// can't read, when it isn't SVG or when its elements nest deeper than the SVGs around it /// leave: usvg reads it while it reads them, on the same stack. Only a decoded image, with the /// images decoded inside it, counts toward [`MAX_NESTED_SVG_IMAGES`]; every image read counts - /// toward [`MAX_NESTED_SVG_READS`] and [`MAX_NESTED_SVG_TEXT`]. - fn svg(&self, text: &str, opts: &usvg::Options) -> Option { + /// toward [`MAX_NESTED_SVG_READS`] and [`MAX_NESTED_SVG_TEXT`]. `file` is the linked SVG file + /// the text is, whose own links the hrefs in it are looked up in. + fn svg(&self, text: &str, file: Option>, opts: &usvg::Options) -> Option { let decoded = self.svg_images.load(Ordering::Relaxed); if decoded >= MAX_NESTED_SVG_IMAGES { return leave_out(&self.too_many_svg_images, true); @@ -345,7 +550,7 @@ impl Files { // The image's place is taken while usvg reads it, so the images inside it get what is left. // usvg reads them all on this thread, so no other read comes between these steps. self.svg_images.store(decoded + 1, Ordering::Relaxed); - let tree = self.svg_tree(text, opts); + let tree = self.svg_tree(text, file, opts); if tree.is_none() { self.svg_images.store(decoded, Ordering::Relaxed); } @@ -353,7 +558,7 @@ impl Files { } /// [`Files::svg`]'s image once it is counted. - fn svg_tree(&self, text: &str, opts: &usvg::Options) -> Option { + fn svg_tree(&self, text: &str, file: Option>, opts: &usvg::Options) -> Option { // Measured as the SVG holding it is (`import`): the text before the XML reader. let used = self.depth.load(Ordering::Relaxed); let left = MAX_ELEMENT_DEPTH.saturating_sub(used); @@ -365,7 +570,27 @@ impl Files { // usvg's parser would overflow on it, as on the SVG holding it (`import`). super::convolve_matrices_ok(&xml).ok()?; self.depth.store(used + height, Ordering::Relaxed); - let tree = usvg::Tree::from_data_nested(text.as_bytes(), opts); + let within = self.within.lock().ok().map(|mut w| w.push(file)).is_some(); + // usvg's `Tree::from_data_nested`, except that hrefs to files go to the resolver. + let nested = usvg::Options { + resources_dir: None, + dpi: opts.dpi, + font_size: opts.font_size, + languages: opts.languages.clone(), + shape_rendering: opts.shape_rendering, + text_rendering: opts.text_rendering, + image_rendering: opts.image_rendering, + default_size: opts.default_size, + image_href_resolver: ImageHrefResolver { + resolve_data: Box::new(|mime, bytes, o| (opts.image_href_resolver.resolve_data)(mime, bytes, o)), + resolve_string: Box::new(|h, o| (opts.image_href_resolver.resolve_string)(h, o)), + }, + ..usvg::Options::default() + }; + let tree = usvg::Tree::from_str(text, &nested); + if within && let Ok(mut w) = self.within.lock() { + w.pop(); + } self.depth.store(used, Ordering::Relaxed); let tree = tree.ok()?; self.note_text(text); @@ -630,6 +855,131 @@ mod tests { assert!(read("1")); } + /// An SVG file of 2 × 2 showing `hrefs`. + fn linking(hrefs: &[&str]) -> Vec { + let images: String = hrefs.iter().map(|h| format!(r#""#)).collect(); + format!(r#"{images}"#).into_bytes() + } + + /// The links of the SVG showing `hrefs` read as [`Files::read`] reads them, the files `served` + /// by name, with the SVG files and bytes read for links starting at `reads` and `text`: the + /// names of the files read, in order, and the files. + fn read_links(hrefs: &[&str], served: &[(&str, Vec)], reads: usize, text: u64) -> (Vec, Files) { + let log = Mutex::new(vec![]); + let read = |path: &str| { + let name = Path::new(path).file_name()?.to_string_lossy().into_owned(); + log.lock().ok()?.push(name.clone()); + served.iter().find(|(n, _)| *n == name).map(|(_, bytes)| (bytes.clone(), LinkInfo::new(path))) + }; + let folder = std::env::temp_dir().join("vectorcraft-svg-limits"); + let folder = folder.to_string_lossy(); + let opts = ImportOptions { folder: Some(&folder), read: Some(&read) }; + let svg = String::from_utf8(linking(hrefs)).unwrap(); + let xml = super::super::parse(&svg).unwrap(); + let mut warnings = vec![]; + let mut reading = Reading { + opts: &opts, + warnings: &mut warnings, + placeholder: None, + by_file: HashMap::new(), + active: vec![], + placed: false, + svg_reads: reads, + svg_text: text, + }; + let mut files = Files::default(); + files.sources = files.links_of(&svg, &xml, opts.folder, None, &mut Edits::default(), &mut reading); + (log.into_inner().unwrap(), files) + } + + fn png() -> Vec { + let mut out = vec![]; + image::RgbaImage::new(1, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + } + + fn linked_note() -> String { + format!( + "images linked from linked SVG files were left out: once {MAX_NESTED_SVG_READS} SVG files or {} MB have been read for their links, the files they link to aren't read", + MAX_NESTED_SVG_TEXT >> 20 + ) + } + + /// Every file read for a linked SVG file's links counts toward [`MAX_NESTED_SVG_TEXT`], a + /// raster too, after the linked SVG file's own text: the file that passes it is left out, and + /// no file after it is read. + #[test] + fn linked_files_past_the_text_limit_are_not_read() { + let a = linking(&["one.png", "two.png", "three.png"]); + let served = [("a.svg", a.clone()), ("one.png", png()), ("two.png", png()), ("three.png", png())]; + let (log, files) = read_links(&["a.svg"], &served, 0, MAX_NESTED_SVG_TEXT - (png().len() + a.len()) as u64); + assert_eq!(log, ["a.svg", "one.png", "two.png"]); + let Some(Source::Svg(a)) = files.sources.get("a.svg") else { panic!("a.svg is shown") }; + assert!(a.sources.contains_key("one.png") && a.sources.len() == 1, "{:?}", a.sources.keys()); + assert_eq!(files.left_out(), [linked_note()]); + } + + /// Once more than [`MAX_NESTED_SVG_READS`] SVG files are read for their links, the SVG file + /// past it is left out inside a linked SVG file, and no file a linked SVG file links to is + /// read (a nested link past the limit); rasters the imported SVG links to still are. + #[test] + fn linked_files_past_the_read_limit_are_not_read() { + let served = [("a.svg", linking(&["b.svg", "two.png"])), ("b.svg", linking(&["one.png"])), ("one.png", png()), ("two.png", png())]; + let (log, files) = read_links(&["a.svg", "two.png"], &served, MAX_NESTED_SVG_READS - 1, 0); + assert_eq!(log, ["a.svg", "b.svg", "two.png"]); + let Some(Source::Svg(a)) = files.sources.get("a.svg") else { panic!("a.svg is shown") }; + assert!(a.sources.is_empty(), "{:?}", a.sources.keys()); + assert!(matches!(files.sources.get("two.png"), Some(Source::Raster(..)))); + assert_eq!(files.left_out().len(), 2, "{:?}", files.left_out()); + assert!(files.left_out().contains(&linked_note())); + } + + /// Reading the SVG files the imported SVG links to, and their links, counts toward the + /// limits: past them, such a file is still shown, but the files it links to aren't read. + #[test] + fn svg_files_the_imported_svg_links_to_are_shown_without_their_links_past_the_limits() { + let served = [("a.svg", linking(&["one.png"])), ("one.png", png())]; + for (reads, text) in [(MAX_NESTED_SVG_READS, 0), (0, MAX_NESTED_SVG_TEXT)] { + let (log, files) = read_links(&["a.svg"], &served, reads, text); + assert_eq!(log, ["a.svg"], "one.png isn't read"); + let Some(Source::Svg(a)) = files.sources.get("a.svg") else { panic!("a.svg is shown") }; + assert!(a.sources.is_empty(), "{:?}", a.sources.keys()); + assert_eq!(files.left_out(), [linked_note()]); + } + // More of them than the limit: those past it are shown without their links. + let names: Vec = (0..MAX_NESTED_SVG_READS + 2).map(|i| format!("s{i}.svg")).collect(); + let hrefs: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut served: Vec<(&str, Vec)> = hrefs.iter().map(|h| (*h, linking(&["one.png"]))).collect(); + served.push(("one.png", png())); + let (log, files) = read_links(&hrefs, &served, 0, 0); + assert_eq!(log.len(), hrefs.len() + 1, "one.png is read once"); + let with_links = hrefs.iter().filter(|h| matches!(files.sources.get(**h), Some(Source::Svg(s)) if s.sources.contains_key("one.png"))); + assert_eq!(with_links.count(), MAX_NESTED_SVG_READS); + assert!(hrefs.iter().all(|h| matches!(files.sources.get(*h), Some(Source::Svg(_)))), "all shown"); + assert_eq!(files.left_out(), [linked_note()]); + } + + /// An SVG file read once is kept by its file wherever it was read: another spelling of its + /// href doesn't read it again, past the limits too. + #[test] + fn an_svg_file_is_read_once_past_the_limits() { + let served = [("a.svg", linking(&["one.png"])), ("one.png", png())]; + let (log, files) = read_links(&["a.svg", "./a.svg", "%61.svg"], &served, MAX_NESTED_SVG_READS, 0); + assert_eq!(log, ["a.svg"]); + assert!(["a.svg", "./a.svg", "%61.svg"].iter().all(|h| matches!(files.sources.get(*h), Some(Source::Svg(_))))); + // Read inside a linked SVG file, where its read passes the text limit: left out there, + // and shown, not read again, where the imported SVG links to it. + let x = linking(&["a.svg"]); + let served = [("x.svg", x.clone()), ("a.svg", linking(&["one.png"])), ("one.png", png())]; + let (log, files) = read_links(&["x.svg", "./a.svg"], &served, 0, MAX_NESTED_SVG_TEXT - x.len() as u64); + assert_eq!(log, ["x.svg", "a.svg"]); + let Some(Source::Svg(x)) = files.sources.get("x.svg") else { panic!("x.svg is shown") }; + assert!(x.sources.is_empty(), "{:?}", x.sources.keys()); + let Some(Source::Svg(a)) = files.sources.get("./a.svg") else { panic!("a.svg is shown") }; + assert!(a.sources.is_empty(), "{:?}", a.sources.keys()); + assert_eq!(files.left_out().len(), 2, "{:?}", files.left_out()); + } + /// The nested SVG images read, decoded or not, take at most [`MAX_NESTED_SVG_TEXT`] bytes of /// text together: an image whose text goes past it is left out and noted. A broken image's /// text counts too, and its decoded place is given back. diff --git a/crates/svg/src/import/prepass.rs b/crates/svg/src/import/prepass.rs index 95c0d57203..3d1b04ad60 100644 --- a/crates/svg/src/import/prepass.rs +++ b/crates/svg/src/import/prepass.rs @@ -109,7 +109,7 @@ impl Edits { /// `svg` with the edits made; overlapping ones and ranges off character boundaries are /// skipped. - fn apply(mut self, svg: &str) -> Cow<'_, str> { + pub(super) fn apply(mut self, svg: &str) -> Cow<'_, str> { self.list.append(&mut self.id_edits); for (at, has_style, decls) in std::mem::take(&mut self.styles).into_values() { self.insert(at, if has_style { decls } else { format!(" style=\"{}\"", decls.strip_prefix(';').unwrap_or(&decls)) }); diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index f7ba77cea2..bbe72063e1 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -965,6 +965,12 @@ impl Ctx { "normal".into() } + /// Whether the computed `direction` of element `n` is `rtl`, in any letter case (CSS keywords + /// are ASCII case-insensitive, https://www.w3.org/TR/css-values-3/#keywords). + fn is_rtl(&self, n: XNode) -> bool { + self.prop(n, "direction").is_some_and(|d| d.trim().eq_ignore_ascii_case("rtl")) + } + /// The Character Direction of the characters of element `n` inside a ``, and what it /// leaves out (SVG 1.1 §10.7.4, /// https://www.w3.org/TR/SVG11/text.html#RelationshipWithBi-directionality, and CSS Writing @@ -987,7 +993,7 @@ impl Ctx { let inherited = parent.as_ref().and_then(|p| p.style.char_direction); let mut notes = parent.map_or_else(Vec::new, |p| p.bidi_notes.clone()); let rtl = |rtl: bool| Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }); - let own = || rtl(self.prop(n, "direction").is_some_and(|d| d.trim() == "rtl")); + let own = || rtl(self.is_rtl(n)); let (dir, note) = match bidi.as_str() { "embed" => (own(), None), "bidi-override" => (own(), Some(BIDI_OVERRIDE)), @@ -1376,7 +1382,7 @@ impl Ctx { // With `unicode-bidi: plaintext`, the text runs in the direction of its first strong // character, and each paragraph in that of its own ([`pin_first_strong`]). let plaintext = self.unicode_bidi(t) == "plaintext"; - let rtl = if plaintext { self.first_strong_rtl(t) == Some(true) } else { self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") }; + let rtl = if plaintext { self.first_strong_rtl(t) == Some(true) } else { self.is_rtl(t) }; let pin = |obj: &mut TextObject| { pin_direction(obj, rtl); if plaintext { @@ -1403,7 +1409,7 @@ impl Ctx { let chunk: String = cs.chars[i..end].iter().map(|c| c.0).collect(); text_first_strong_rtl(&chunk) == Some(true) }; - let rtl = if plaintext { chunk_rtl() } else { self.prop(el, "direction").is_some_and(|d| d.trim() == "rtl") }; + let rtl = if plaintext { chunk_rtl() } else { self.is_rtl(el) }; if !vertical && rtl { 1.0 - a } else { a } }; let anchor = anchor_of(0); diff --git a/crates/svg/tests/it/blend_export.rs b/crates/svg/tests/it/blend_export.rs index dd86f318f5..ec947041b0 100644 --- a/crates/svg/tests/it/blend_export.rs +++ b/crates/svg/tests/it/blend_export.rs @@ -202,6 +202,212 @@ fn a_mask_region_keeps_the_shadows_of_the_masked_art() { } } +fn drop_shadow() -> vectorcraft_doc::Effect { + vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}), + visible: true, + } +} + +/// Whether every `` in `svg` starts with a hidden, unpainted rectangle over its mask's region, +/// and whether any does. +fn spanning_rects(svg: &str) -> (bool, bool) { + let spans = masked_group_spans(svg); + (spans.iter().all(|s| *s), spans.iter().any(|s| *s)) +} + +/// For each `` in `svg`, in document order, whether it starts with a hidden, unpainted +/// rectangle over its mask's region. +fn masked_group_spans(svg: &str) -> Vec { + let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap(); + let spans: Vec = xml + .descendants() + .filter(|n| n.has_tag_name("g") && n.has_attribute("mask")) + .map(|group| { + let id = group.attribute("mask").unwrap().trim_start_matches("url(#").trim_end_matches(')'); + let mask = xml.descendants().find(|n| n.has_tag_name("mask") && n.attribute("id") == Some(id)).unwrap(); + let first = group.children().find(|n| n.is_element()).unwrap(); + first.has_tag_name("rect") + && first.attribute("fill") == Some("none") + && first.attribute("visibility") == Some("hidden") + && ["x", "y", "width", "height"].iter().all(|a| first.attribute(*a).is_some() && first.attribute(*a) == mask.attribute(*a)) + }) + .collect(); + assert!(!spans.is_empty(), "no masked group in {svg}"); + spans +} + +/// The number of paths `svg` imports as. +fn imported_paths(svg: &str) -> usize { + let (back, warnings) = vectorcraft_svg::import_with_report(svg).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let mut paths = 0; + back.walk(|n| paths += usize::from(n.path_data().is_some())); + paths +} + +/// A masked group whose content holds a raster effect (a drop shadow, blur, glow or feather, which +/// paint past the shapes) starts with a hidden, unpainted rectangle over the mask's region, so that +/// a reader that draws a masked group only inside the box of its shapes still draws it. Art without +/// one gets none, brushed strokes (written as shapes) included, nor does an object whose own shadow +/// is drawn outside the mask. Import leaves the rectangle out. +#[test] +fn a_masked_group_spans_its_region_when_its_art_paints_past_its_shapes() { + // How the red square sits under the mask. + #[derive(Debug, Clone, Copy)] + enum Case { + Alone, + InGroup, + InNestedGroup, + FillEffect, + HiddenFill, + UnpaintedStroke, + HiddenInGroup, + Brushed, + InSymbol, + OverriddenSymbol, + InBlend, + } + use Case::*; + for (case, shadowed, want) in [ + (InGroup, true, true), + (InGroup, false, false), + (Alone, true, false), + (Alone, false, false), + (InNestedGroup, true, true), + (FillEffect, true, true), + (FillEffect, false, false), + // A fill or stroke that isn't written doesn't count. + (HiddenFill, true, false), + (UnpaintedStroke, true, false), + (HiddenInGroup, true, false), + (Brushed, false, false), + (Brushed, true, true), + (InSymbol, true, true), + (InSymbol, false, false), + // An unpainted fill in a dynamic symbol's art that the instance paints counts. + (OverriddenSymbol, true, true), + (OverriddenSymbol, false, false), + (InBlend, true, true), + (InBlend, false, false), + ] { + let mut d = Document::new(100.0, 60.0); + let mut red = rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), [255, 0, 0]); + if matches!(case, Brushed) { + let mut stroke = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::rgb8(0, 0, 200)), 8.0); + stroke.brush = Some("Bristle Round".into()); + red.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(stroke)); + } + if matches!(case, UnpaintedStroke) { + red.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::None, 8.0))); + } + if shadowed { + match case { + FillEffect | HiddenFill | OverriddenSymbol => red.appearance.items[0].effects_mut().push(drop_shadow()), + UnpaintedStroke => red.appearance.items.last_mut().unwrap().effects_mut().push(drop_shadow()), + _ => red.appearance.effects.push(drop_shadow()), + } + } + if matches!(case, HiddenFill) + && let vectorcraft_doc::AppearanceItem::Fill(f) = &mut red.appearance.items[0] + { + f.visible = false; + } + if matches!(case, OverriddenSymbol) { + red.appearance.set_fill(Paint::None); + } + let group = |d: &mut Document, n: Node| Node::group(d.alloc_id(), vec![n.into()]); + let mut masked = match case { + Alone | FillEffect => red, + HiddenFill | UnpaintedStroke => group(&mut d, red), + InGroup | Brushed => group(&mut d, red), + InNestedGroup => { + let inner = group(&mut d, red); + group(&mut d, inner) + } + HiddenInGroup => { + red.visible = false; + group(&mut d, red) + } + InSymbol => { + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Square".into(), art: std::sync::Arc::new(red) }); + let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Square".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + group(&mut d, instance) + } + OverriddenSymbol => { + d.symbols.push(vectorcraft_doc::Symbol { dynamic: true, name: "Square".into(), art: std::sync::Arc::new(red) }); + let mut instance = + Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Square".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let fill = Some(Paint::solid(Color::rgb8(255, 0, 0))); + instance.extras_mut().symbol_overrides.push(vectorcraft_doc::symbol_overrides::SymbolOverride { fill, ..Default::default() }); + group(&mut d, instance) + } + InBlend => { + let mut blue = rect(&mut d, Rect::new(60.0, 10.0, 80.0, 30.0), [0, 0, 255]); + blue.appearance.effects = red.appearance.effects.clone(); + let blend = Node::new(d.alloc_id(), NodeKind::Blend { children: vec![red.into(), blue.into()], spec: Default::default() }); + group(&mut d, blend) + } + }; + let art = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 30.0), [255, 255, 255]); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, masked); + let svg = export(&d, &ExportOptions::default()); + assert_eq!(spanning_rects(&svg).0, want, "{case:?}, shadowed {shadowed}:\n{svg}"); + if matches!(case, Alone | InGroup | InNestedGroup | FillEffect) { + // The red square alone: no rectangle of the region's size comes back. + assert_eq!(imported_paths(&svg), 1, "{case:?}, shadowed {shadowed}:\n{svg}"); + } + } + // Type can't be measured, so its mask's region covers any artwork: a group of type gets the + // rectangle only when it holds a raster effect, on the type or on a path beside it. + for (shadowed, path_shadowed) in [(false, false), (true, false), (false, true)] { + let mut d = Document::new(80.0, 40.0); + let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 30.0), "Wide", style)))); + if shadowed { + t.appearance.effects.push(drop_shadow()); + } + let mut red = rect(&mut d, Rect::new(50.0, 5.0, 60.0, 15.0), [255, 0, 0]); + if path_shadowed { + red.appearance.effects.push(drop_shadow()); + } + let mut group = Node::group(d.alloc_id(), vec![t.into(), red.into()]); + let art = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [255, 255, 255]); + group.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + add(&mut d, group); + let svg = export(&d, &ExportOptions::default()); + let want = shadowed || path_shadowed; + assert_eq!(svg.contains("visibility=\"hidden\""), want, "type {shadowed}, path {path_shadowed}:\n{svg}"); + } +} + +/// In a knockout group, each element is drawn inside one mask of the elements above it. When the +/// element inside a mask's group has a drop shadow, that group starts with the same hidden, +/// unpainted rectangle over the region, the other groups don't, and import leaves it out. +#[test] +fn a_knockout_group_spans_its_masks_when_an_element_paints_past_its_shapes() { + for shadowed in [false, true] { + let mut d = Document::new(60.0, 60.0); + let mut below = rect(&mut d, Rect::new(5.0, 5.0, 25.0, 25.0), [200, 0, 0]); + if shadowed { + below.appearance.effects.push(drop_shadow()); + } + let mut middle = rect(&mut d, Rect::new(15.0, 15.0, 35.0, 35.0), [0, 200, 0]); + middle.opacity = 0.5; + let mut top = rect(&mut d, Rect::new(20.0, 5.0, 30.0, 40.0), [0, 0, 200]); + top.opacity = 0.5; + let mut group = Node::group(d.alloc_id(), vec![below.into(), middle.into(), top.into()]); + group.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, group); + let svg = export(&d, &ExportOptions::default()); + // The bottom and middle elements are masked, in paint order; the top one isn't. + assert_eq!(masked_group_spans(&svg), [shadowed, false], "shadowed {shadowed}:\n{svg}"); + assert_eq!(imported_paths(&svg), 3, "shadowed {shadowed}:\n{svg}"); + } +} + /// Type is measured by its layout or its path, and a reader sets live type in its own font, so a /// mask or knockout over type keeps the region that covers any artwork. #[test] diff --git a/crates/svg/tests/it/image_orientation.rs b/crates/svg/tests/it/image_orientation.rs index fbf412a0bb..549a40a3bf 100644 --- a/crates/svg/tests/it/image_orientation.rs +++ b/crates/svg/tests/it/image_orientation.rs @@ -276,3 +276,18 @@ fn a_turned_jpeg_in_an_svg_image_imports_upright() { let px = pixels(&d, &im); assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12))); } + +/// A turned JPEG that a linked SVG file links to is turned too. +#[test] +fn a_turned_jpeg_linked_from_a_linked_svg_file_imports_upright() { + let dir = folder("nested-file"); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::write(dir.join("sub").join("photo.jpg"), jpeg(Some(6))).unwrap(); + let inner = r#""#; + std::fs::write(dir.join("sub").join("inner.svg"), inner).unwrap(); + let (d, im) = import_one(r#""#, Some(&dir)); + assert_eq!((im.width, im.height), (8, 16)); + assert!(near(placed(&im), Rect::new(20.0, 0.0, 60.0, 80.0)), "{:?}", placed(&im)); + let px = pixels(&d, &im); + assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12))); +} diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index b043e4d985..69e5cafa21 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -216,6 +216,185 @@ fn svgz_images_import_as_vector_art() { assert!((boxes[1].x0 - 100.0).abs() < 1e-6 && (boxes[1].height() - 20.0).abs() < 1e-6, "{boxes:?}"); } +/// An SVG file of 20 × 20 holding a 5 × 5 rectangle and `images`. +fn svg_file(images: &str) -> String { + format!(r#"{images}"#) +} + +/// Reads as [`read`] does, counting the reads of each file name. +fn counting(reads: &std::sync::Mutex>) -> impl Fn(&str) -> Option<(Vec, LinkInfo)> + Sync + '_ { + move |path: &str| { + if let Ok(mut reads) = reads.lock() { + reads.push(Path::new(path).file_name().unwrap().to_string_lossy().into_owned()); + } + read(path) + } +} + +/// A raster an SVG file links to is read from that file's folder and stays linked, in its place in +/// the SVG file's art. +#[test] +fn a_raster_linked_from_a_linked_svg_file_is_read_from_its_folder() { + let dir = folder("nested-link"); + let bytes = png(40, 20); + std::fs::write(dir.join("img").join("photo.png"), &bytes).unwrap(); + std::fs::write(dir.join("img").join("inner.svg"), svg_file(r#""#)).unwrap(); + let (d, w) = open_in(&dir, r#""#); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(rects(&d), 1, "the SVG file's art"); + let [(_, im)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; + let link = im.link.as_ref().unwrap(); + assert_eq!(Path::new(&link.path), dir.join("img").join("photo.png")); + assert_eq!(*d.images[&im.key].bytes, bytes, "the file's pixels"); + let b = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, 40.0, 20.0)); + assert!((b.x0 - 12.0).abs() < 1e-6 && (b.y0 - 14.0).abs() < 1e-6 && (b.width() - 10.0).abs() < 1e-6, "{b:?}"); +} + +/// One href in two SVG files of different folders names two files: each shows its own. +#[test] +fn one_href_in_svg_files_of_different_folders_shows_each_folders_file() { + let dir = folder("nested-same-href"); + let (outer, inner) = (png(4, 4), png(6, 6)); + std::fs::write(dir.join("photo.png"), &outer).unwrap(); + std::fs::write(dir.join("img").join("photo.png"), &inner).unwrap(); + std::fs::write(dir.join("img").join("inner.svg"), svg_file(r#""#)).unwrap(); + let (d, w) = open_in(&dir, r#""#); + assert!(w.is_empty(), "{w:?}"); + let [(_, a), (_, b)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; + assert_eq!(Path::new(&a.link.as_ref().unwrap().path), dir.join("photo.png")); + assert_eq!(*d.images[&a.key].bytes, outer); + assert_eq!(Path::new(&b.link.as_ref().unwrap().path), dir.join("img").join("photo.png")); + assert_eq!(*d.images[&b.key].bytes, inner); +} + +/// A raster an SVG file links to that can't be read (missing, or refused by the reader) gets the +/// placeholder, stretched to its box, its link and a warning, as one the imported SVG links to. +#[test] +fn a_missing_raster_linked_from_a_linked_svg_file_keeps_a_placeholder() { + let dir = folder("nested-missing"); + std::fs::write(dir.join("img").join("secret.png"), png(4, 4)).unwrap(); + let links = r#""#; + std::fs::write(dir.join("img").join("inner.svg"), svg_file(links)).unwrap(); + let refusing = |path: &str| if path.ends_with("secret.png") { None } else { read(path) }; + let folder = dir.to_string_lossy(); + let body = r#""#; + let (d, w) = import_with(&svg(body), &ImportOptions { folder: Some(&folder), read: Some(&refusing) }).unwrap(); + for name in ["gone.png", "secret.png"] { + assert!(w.iter().any(|w| w.contains(&format!("'{name}' not found"))), "{name}: {w:?}"); + } + let [(_, gone), (_, secret)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; + assert_eq!(Path::new(&gone.link.as_ref().unwrap().path), dir.join("img").join("gone.png")); + assert_eq!(Path::new(&secret.link.as_ref().unwrap().path), dir.join("img").join("secret.png")); + assert!(d.images[&gone.key].is_proxy() && d.images[&secret.key].is_proxy(), "placeholders, not the files"); + let b = gone.xf.transform_rect_bbox(Rect::new(0.0, 0.0, gone.width as f64, gone.height as f64)); + assert!((b.x0 - 12.0).abs() < 1e-6 && (b.width() - 10.0).abs() < 1e-6 && (b.height() - 5.0).abs() < 1e-6, "{b:?}"); +} + +/// An SVG file that links to itself, or to an SVG file that links back to it, is read once each +/// time it is shown: the link back gets the placeholder and a warning. +#[test] +fn svg_files_that_link_back_keep_a_placeholder_where_they_lead_back() { + let dir = folder("nested-cycle"); + std::fs::write(dir.join("img").join("self.svg"), svg_file(r#""#)).unwrap(); + std::fs::write(dir.join("img").join("a.svg"), svg_file(r#""#)).unwrap(); + std::fs::write(dir.join("img").join("b.svg"), svg_file(r#""#)).unwrap(); + let folder = dir.to_string_lossy(); + for (body, files, back) in [("img/self.svg", vec!["self.svg"], "self.svg"), ("img/a.svg", vec!["a.svg", "b.svg"], "./a.svg")] { + let reads = std::sync::Mutex::new(vec![]); + let counted = counting(&reads); + let body = format!(r#""#); + let (d, w) = import_with(&svg(&body), &ImportOptions { folder: Some(&folder), read: Some(&counted) }).unwrap(); + assert_eq!(*reads.lock().unwrap(), [files.clone(), files.clone()].concat()); + let warning = format!("linked image '{back}' leads back to an SVG file that shows it: a placeholder keeps its place until it is relinked"); + assert_eq!(w, [warning.clone(), warning]); + assert_eq!(rects(&d), 2 * files.len(), "each file's art once in each place"); + let found = images(&d); + assert_eq!(found.len(), 2); + for (_, im) in found { + assert!(d.images[&im.key].is_proxy()); + assert_eq!(Path::new(&im.link.as_ref().unwrap().path), dir.join("img").join(back.trim_start_matches("./"))); + } + } +} + +/// Two SVG files that link to each other, each shown by the imported SVG, each show the other +/// once, then the placeholder of the link back to themselves. +#[test] +fn svg_files_that_link_to_each_other_each_lead_back_to_themselves() { + let dir = folder("nested-cycle-both"); + std::fs::write(dir.join("img").join("a.svg"), svg_file(r#""#)).unwrap(); + std::fs::write(dir.join("img").join("b.svg"), svg_file(r#""#)).unwrap(); + let (d, w) = open_in(&dir, r#""#); + assert_eq!(w.len(), 2, "{w:?}"); + assert_eq!(rects(&d), 4, "each file's art once in each place"); + let [(_, a), (_, b)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; + assert_eq!(Path::new(&a.link.as_ref().unwrap().path), dir.join("img").join("a.svg")); + assert_eq!(Path::new(&b.link.as_ref().unwrap().path), dir.join("img").join("b.svg")); +} + +/// An SVG file shown twice, and a raster that two SVG files link to, are read once. +#[test] +fn a_repeated_linked_svg_file_is_read_once() { + let dir = folder("nested-repeat"); + std::fs::write(dir.join("img").join("photo.png"), png(4, 4)).unwrap(); + std::fs::write(dir.join("img").join("a.svg"), svg_file(r#""#)).unwrap(); + std::fs::write( + dir.join("img").join("b.svg"), + svg_file(r#""#), + ) + .unwrap(); + let reads = std::sync::Mutex::new(vec![]); + let counted = counting(&reads); + let folder = dir.to_string_lossy(); + let body = r#""#; + let (d, w) = import_with(&svg(body), &ImportOptions { folder: Some(&folder), read: Some(&counted) }).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let mut reads = reads.lock().unwrap().clone(); + reads.sort(); + assert_eq!(reads, ["a.svg", "b.svg", "photo.png"]); + let found = images(&d); + assert_eq!(found.len(), 4); + assert!(found.iter().all(|(_, im)| Path::new(&im.link.as_ref().unwrap().path) == dir.join("img").join("photo.png"))); +} + +/// A chain of SVG files, each linking to the next, is read only as deep as SVG images may nest: +/// past it the images are left out, with a warning. +#[test] +fn a_chain_of_linked_svg_files_stops_at_the_depth_cap() { + let dir = folder("nested-chain"); + let levels = vectorcraft_svg::MAX_ELEMENT_DEPTH; + for i in 0..levels { + std::fs::write(dir.join("img").join(format!("{i}.svg")), svg_file(&format!(r#""#, i + 1))) + .unwrap(); + } + let reads = std::sync::Mutex::new(vec![]); + let counted = counting(&reads); + let folder = dir.to_string_lossy(); + let (d, w) = + import_with(&svg(r#""#), &ImportOptions { folder: Some(&folder), read: Some(&counted) }) + .unwrap(); + let reads = reads.lock().unwrap().len(); + assert!(reads <= levels / 2 + 4, "{reads} reads"); + assert!(rects(&d) < levels / 2, "{}", rects(&d)); + assert!(w.iter().any(|w| w.contains("nest more than")), "{w:?}"); +} + +/// An SVG file reached first inside an SVG file where it nests too deep to be shown, then from the +/// imported SVG, shows the raster it links to where it fits. +#[test] +fn an_svg_file_too_deep_in_one_place_shows_its_links_in_another() { + let dir = folder("nested-deep-then-shallow"); + let depth = vectorcraft_svg::MAX_ELEMENT_DEPTH; + let nest = |n: usize, inner: &str| format!("{}{inner}{}", "".repeat(n), "".repeat(n)); + std::fs::write(dir.join("img").join("photo.png"), png(4, 4)).unwrap(); + std::fs::write(dir.join("img").join("deep.svg"), svg_file(&nest(depth * 2 / 3, r#""#))).unwrap(); + std::fs::write(dir.join("img").join("a.svg"), svg_file(&nest(depth / 2, r#""#))).unwrap(); + let (d, w) = open_in(&dir, r#""#); + assert!(w.iter().any(|w| w.contains("nest more than")), "{w:?}"); + let [(_, im)] = images(&d)[..] else { panic!("{:?} {w:?}", art(&d)) }; + assert_eq!(Path::new(&im.link.as_ref().unwrap().path), dir.join("img").join("photo.png")); +} + const SVG_NS: &str = r#"xmlns="http://www.w3.org/2000/svg""#; /// An image showing `svg`, from a base64 data URL of type `mime` (none when empty). diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 084c3172f3..d55e8b930e 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -469,6 +469,9 @@ fn rtl_point_text_start_and_end_use_directional_edges() { (r#"Wave"#, 0.0), (r#"Wave"#, 0.0), (r#"Wave"#, 1.0), + // CSS keywords are case-insensitive. + (r#"Wave"#, 1.0), + (r#"Wave"#, 0.0), // With `unicode-bidi: plaintext`, the direction of the chunk's first strong character. (r#"אבג"#, 1.0), (r#"12 אב"#, 0.0), @@ -572,6 +575,8 @@ fn a_tspan_bidi_embedding_becomes_its_character_direction() { ("ltr", "rtl", "normal", None, false), ("rtl", "ltr", "normal", None, false), ("ltr", "rtl", "EMBED", Some(Rtl), false), + ("ltr", "RTL", "embed", Some(Rtl), false), + ("RTL", "ltr", "embed", Some(Ltr), false), ("ltr", "rtl", "sideways", None, false), ] { let (t, w) = read(&format!( @@ -581,7 +586,7 @@ fn a_tspan_bidi_embedding_becomes_its_character_direction() { assert_eq!(t.plain_text(), "ab 1 2 cd", "{case}: the characters stay in logical order"); assert_eq!(char_direction_of(&t, "1 2"), want, "{case}: {:?}", t.runs); assert_eq!(char_direction_of(&t, "ab"), None, "{case}"); - assert_eq!(t.para_at(0).direction == Some(Rtl), outer == "rtl", "{case}: the paragraph keeps the text's direction"); + assert_eq!(t.para_at(0).direction == Some(Rtl), outer.eq_ignore_ascii_case("rtl"), "{case}: the paragraph keeps the text's direction"); assert_eq!(w.iter().any(|w| w.contains("bidi-override") && w.contains("not supported")), warns, "{case}: {w:?}"); if !warns { assert!(w.is_empty(), "{case}: {w:?}"); diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 1ee9a3fe50..e7753c8c04 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -544,6 +544,9 @@ struct LineJustification { fit: LineFit, start_x: f64, after: Vec>, + /// The warichu glyphs drawn inside a block already started, by offset from the line's first + /// glyph ([`blocks_begun`]). + begun: Vec, kashidas: Vec<(usize, f64)>, /// Right-aligned area type: the line's last tab, which takes the text after it to the right /// edge (the text before it sits at its stops, from the left edge). @@ -805,14 +808,14 @@ fn visible_after(index: usize, glyph: usize, after: &[Option], sg: &[SGly next } -// The glyph after which a gap between drawn glyphs opens, and the visible glyph that follows it: -// not a held glyph, and never a zero-width format control on either side. -fn gap_pair<'a>(glyph: usize, held: &[usize], after: &[Option], index: usize, sg: &'a [SGlyph]) -> Option<(&'a SGlyph, &'a SGlyph)> { +// The glyph after which a gap between drawn glyphs opens, and the visible glyph that follows it +// with its index: not a held glyph, and never a zero-width format control on either side. +fn gap_pair<'a>(glyph: usize, held: &[usize], after: &[Option], index: usize, sg: &'a [SGlyph]) -> Option<(&'a SGlyph, &'a SGlyph, usize)> { if held.contains(&glyph) { return None; } let a = sg.get(glyph).filter(|a| !is_format_control(a.ch))?; - visible_after(index, glyph, after, sg).and_then(|next| sg.get(next)).map(|b| (a, b)) + visible_after(index, glyph, after, sg).and_then(|next| sg.get(next).map(|b| (a, b, next))) } // Are `a` and `b` glyphs of the same warichu block? Right to left, the bidi order can draw a @@ -821,18 +824,61 @@ fn same_warichu_block(a: &SGlyph, b: &SGlyph) -> bool { a.wari.zip(b.wari).is_some_and(|(first, second)| first.start == second.start) } +// Is glyph `next` drawn inside a block the line has already started: a tate-chu-yoko glyph after +// its block's first, or a warichu glyph drawn after another glyph of its block (`begun`, by offset +// from the line's first glyph, from [`blocks_begun`])? A warichu block starts at the first of its +// glyphs drawn on the line, which right to left is not the block's first glyph. +fn continues_drawn_block(next: usize, index: usize, begun: &[bool], sg: &[SGlyph]) -> bool { + let Some(b) = sg.get(next) else { return false }; + match (b.wari, next.checked_sub(index).and_then(|k| begun.get(k))) { + (Some(_), Some(&begun)) => begun, + _ => b.continues_block(), + } +} + +// The warichu glyphs of `line` drawn after another glyph of their block, by offset from the line's +// first glyph. A block the bidi order interleaves with another keeps its start. +fn blocks_begun(line: &LineGeometry, sg: &[SGlyph]) -> Vec { + let len = line.body.saturating_sub(line.index); + // Each glyph's block, as the offset of the block's first glyph: a block's glyphs are neighbors + // in `sg`, and a block stays on one line. + let mut first: Vec = Vec::with_capacity(len); + for k in 0..len { + let pair = k.checked_sub(1).and_then(|p| sg.get(line.index + p)).zip(sg.get(line.index + k)); + let block = match first.last() { + Some(&f) if pair.is_some_and(|(a, b)| same_warichu_block(a, b)) => f, + _ => k, + }; + first.push(block); + } + let mut started = vec![false; len]; + let mut begun = vec![false; len]; + for &j in &line.drawn { + let Some(k) = j.checked_sub(line.index).filter(|_| sg.get(j).is_some_and(|g| g.wari.is_some())) else { continue }; + let Some(&f) = first.get(k) else { continue }; + if let (Some(seen), Some(slot)) = (started.get(f).copied(), begun.get_mut(k)) { + *slot = seen; + } + if let Some(seen) = started.get_mut(f) { + *seen = true; + } + } + begun +} + // Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the // gaps next to a CJK character, not inside a Latin word, a tate-chu-yoko or warichu block or a -// shaped cluster. -pub(super) fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option], index: usize, sg: &[SGlyph]) -> bool { - gap_pair(glyph, held, after, index, sg).is_some_and(|(a, b)| { +// shaped cluster. `begun` holds the warichu glyphs drawn inside a block already started +// ([`blocks_begun`]). +pub(super) fn is_cjk_gap(glyph: usize, held: &[usize], (after, begun): (&[Option], &[bool]), index: usize, sg: &[SGlyph]) -> bool { + gap_pair(glyph, held, after, index, sg).is_some_and(|(a, b, next)| { a.byte != b.byte && !a.is_space() && !b.is_space() - && !b.continues_block() && !same_warichu_block(a, b) && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)) + && !continues_drawn_block(next, index, begun, sg) }) && !in_pair(glyph, index, after, sg) } @@ -861,13 +907,20 @@ fn in_pair(glyph: usize, index: usize, after: &[Option], sg: &[SGlyph]) - // its drawn glyphs: not after a held glyph, nor inside a tate-chu-yoko block (one cell) or a // warichu block (set as one piece), a shaped cluster, whose glyphs (a letter and its combining marks, a conjunct and its matra) stay // together, or an inseparable pair. On a line that ends in a drawn hyphen, the gap before the -// hyphen counts too: it follows the line's last visible glyph. -pub(super) fn is_letter_gap(glyph: usize, held: &[usize], after: &[Option], index: usize, sg: &[SGlyph], hyphen: bool) -> bool { +// hyphen counts too: it follows the line's last visible glyph. `begun` is as for [`is_cjk_gap`]. +pub(super) fn is_letter_gap( + glyph: usize, + held: &[usize], + (after, begun): (&[Option], &[bool]), + index: usize, + sg: &[SGlyph], + hyphen: bool, +) -> bool { if held.contains(&glyph) || sg.get(glyph).is_none_or(|a| is_format_control(a.ch)) || in_pair(glyph, index, after, sg) { return false; } match gap_pair(glyph, held, after, index, sg) { - Some((a, b)) => a.byte != b.byte && !b.continues_block() && !same_warichu_block(a, b), + Some((a, b, next)) => a.byte != b.byte && !same_warichu_block(a, b) && !continues_drawn_block(next, index, begun, sg), None => hyphen, } } @@ -974,7 +1027,8 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin *slot = Some(b); } } - let cjk_gaps = (line.index..line.body).filter(|&j| free(j) && is_cjk_gap(j, held, &after, line.index, sg)).count(); + let begun = blocks_begun(line, sg); + let cjk_gaps = (line.index..line.body).filter(|&j| free(j) && is_cjk_gap(j, held, (&after, &begun), line.index, sg)).count(); // Single Word Justification: a justified line holding one word (no word space) is // spread between its letters (Full Justify) or aligned. A tab separates two words. let single_word = justify && fixed.is_empty() && spaces == 0 && cjk_gaps == 0; @@ -1013,7 +1067,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS; if pushed_in { let ems: f64 = (line.index..line.body) - .filter(|&j| free(j) && is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j])) + .filter(|&j| free(j) && is_cjk_gap(j, held, (&after, &begun), line.index, sg) && may_squeeze(&sg[j])) .map(|j| squeeze_em(&sg[j], cx.vertical)) .sum(); if ems > EPS { @@ -1044,7 +1098,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin let used: f64 = flex.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); let rest = room - width - used; let hyphen = line.hyphen.is_some(); - let gaps = (line.index..line.body).filter(|&j| free(j) && is_letter_gap(j, held, &after, line.index, sg, hyphen)).count(); + let gaps = (line.index..line.body).filter(|&j| free(j) && is_letter_gap(j, held, (&after, &begun), line.index, sg, hyphen)).count(); if gaps > 0 && rest > EPS { per_gap = rest / gaps as f64; } @@ -1076,7 +1130,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin _ => line.ax1 + line.opt_r - width, } }; - LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing, fixed } + LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, begun, kashidas, last_tab, trailing, fixed } } /// The advance and leader of tab `glyph_index` of `line`, drawn at `order_index` of @@ -1203,10 +1257,10 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData advance += justification.fit.letter(&flex) + justification.fit.glyph(&flex); scale = justification.fit.glyph_scale(&flex); if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { - if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { + if is_cjk_gap(glyph_index, &line.held, (&justification.after, &justification.begun), index, sg) { advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical); } - } else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) { + } else if is_letter_gap(glyph_index, &line.held, (&justification.after, &justification.begun), index, sg, line.hyphen.is_some()) { advance += justification.per_gap; } } diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index 34c48942d5..b8bad9a246 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -120,7 +120,8 @@ fn near_segment_in_band(a: Point, b: Point, top: f64, bottom: f64, r: f64) -> Op } /// [`band_intervals`] less every x at which some point of the band `top..bottom` is closer than -/// `inset` to an edge of `polys` (holes included), measured from the edge itself. +/// `inset` to an edge of `polys` (holes included), measured from the edge itself. A stretch of an +/// edge that contours meet along bounds no part of the shape, so the inset does not apply beside it. fn band_inset_intervals(polys: &[Vec], top: f64, bottom: f64, inset: f64) -> Vec<(f64, f64)> { let iv = band_intervals(polys, top, bottom); if inset <= 0.0 || iv.is_empty() { @@ -130,10 +131,68 @@ fn band_inset_intervals(polys: &[Vec], top: f64, bottom: f64, inset: f64) return vec![]; } let (top, bottom) = (top.min(bottom), top.max(bottom)); - let near = polys.iter().flat_map(|p| (0..p.len()).filter_map(move |i| near_segment_in_band(p[i], p[(i + 1) % p.len()], top, bottom, inset))); + let edges = boundary_segments(polys, |lo, hi| hi > top - inset && lo < bottom + inset); + let near = edges.into_iter().filter_map(|(a, b)| near_segment_in_band(a, b, top, bottom, inset)); subtract_intervals(&iv, &union_intervals(near.collect())) } +/// The stretches of the edges of `polys` that bound the shape (even-odd), among the edges whose y +/// range `lo..hi` `keep` accepts. Edges that lie exactly along one line and overlap there (contours +/// that meet along an edge, however each one divides it) count by parity, as in +/// [`slant_boundaries`]: a stretch that an even number of them cover has the shape on both sides +/// and is left out. Edges of no length are left out. At most [`MAX_EDGE_PAIRS`] pairs are compared; +/// edges left unpaired count whole. +fn boundary_segments(polys: &[Vec], keep: impl Fn(f64, f64) -> bool) -> Vec<(Point, Point)> { + fn root(parent: &mut [usize], mut i: usize) -> usize { + while parent[i] != i { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + i + } + let mut edges: Vec<(Point, Point)> = polys + .iter() + .flat_map(|p| (0..p.len()).map(move |i| (p[i], p[(i + 1) % p.len()]))) + .filter(|&(a, b)| a != b && keep(a.y.min(b.y), a.y.max(b.y))) + .collect(); + // Edges along one line overlap in x, so only edges whose x ranges overlap are compared. + edges.sort_by(|u, v| u.0.x.min(u.1.x).total_cmp(&v.0.x.min(v.1.x))); + let mut parent: Vec = (0..edges.len()).collect(); + let mut pairs = 0; + 'edges: for (i, &(p, q)) in edges.iter().enumerate() { + // The x ranges of edges along one line can differ by rounding, as in `slant_boundaries`. + let scale = p.x.abs().max(p.y.abs()).max(q.x.abs()).max(q.y.abs()).max(1.0); + let x1 = p.x.max(q.x) + 4.0 * f64::EPSILON * scale; + for (j, &(r, s)) in edges.iter().enumerate().skip(i + 1).take_while(|(_, o)| o.0.x.min(o.1.x) <= x1) { + pairs += 1; + if pairs > MAX_EDGE_PAIRS { + break 'edges; + } + if on_line_exactly(p, q, r) && on_line_exactly(p, q, s) { + let (a, b) = (root(&mut parent, i), root(&mut parent, j)); + parent[a] = b; + } + } + } + let mut groups: Vec<(usize, usize)> = (0..edges.len()).map(|i| (root(&mut parent, i), i)).collect(); + groups.sort_unstable(); + let mut out = Vec::new(); + for run in groups.chunk_by(|a, b| a.0 == b.0) { + if let [(_, i)] = run { + out.push(edges[*i]); + continue; + } + // The stretches an odd number of the edges cover: pairs of their ends, sorted along the + // line by whichever coordinate changes more along it. + let (p, q) = edges[run[0].1]; + let by_x = (q.x - p.x).abs() >= (q.y - p.y).abs(); + let mut ends: Vec = run.iter().flat_map(|&(_, i)| [edges[i].0, edges[i].1]).collect(); + ends.sort_by(|a, b| if by_x { a.x.total_cmp(&b.x) } else { a.y.total_cmp(&b.y) }); + out.extend(ends.as_chunks::<2>().0.iter().filter(|c| c[0] != c[1]).map(|c| (c[0], c[1]))); + } + out +} + /// Union of sorted-or-not intervals. fn union_intervals(mut v: Vec<(f64, f64)>) -> Vec<(f64, f64)> { v.sort_by(|a, b| a.0.total_cmp(&b.0)); @@ -500,6 +559,9 @@ fn eroded_cover(polys: &[Vec], top: f64, bottom: f64, d: f64) -> Vec<(f64 cuts.dedup(); } } + // A stretch of an edge that contours meet along bounds no part of the shape, so the shape is + // not shrunk beside it. + let edges = boundary_segments(polys, |lo, hi| hi > top - r && lo < bottom + r); let mut out = Vec::new(); // A band of no height keeps its two equal cuts: one piece of no height, measured at its y. for w in cuts.windows(2) { @@ -510,13 +572,10 @@ fn eroded_cover(polys: &[Vec], top: f64, bottom: f64, d: f64) -> Vec<(f64 } // The points closer than `d` to an edge make a convex shape, so a vertical line within // the piece lies in it when both its ends do. - let near = polys.iter().flat_map(|p| { - (0..p.len()).filter_map(move |i| { - let (s, t) = (p[i], p[(i + 1) % p.len()]); - let (u, v) = (near_segment(s, t, a, r)?, near_segment(s, t, b, r)?); - let (x, y) = (u.0.max(v.0), u.1.min(v.1)); - (y > x).then_some((x, y)) - }) + let near = edges.iter().filter_map(|&(s, t)| { + let (u, v) = (near_segment(s, t, a, r)?, near_segment(s, t, b, r)?); + let (x, y) = (u.0.max(v.0), u.1.min(v.1)); + (y > x).then_some((x, y)) }); out.extend(subtract_intervals(&cover, &union_intervals(near.collect()))); } @@ -1090,6 +1149,36 @@ fn inset_keeps_text_clear_of_a_hole_s_sloped_edges() { assert_spans(&spans[0], &[(5.0, 36.0 - clear), (64.0 + clear, 95.0)]); } +/// The inset is not kept beside an edge two contours share, vertical, horizontal or slanted with an +/// extra anchor on one side, and is still kept beside the part of an edge only one contour covers. +#[cfg(test)] +#[test] +fn inset_is_not_kept_beside_an_edge_two_contours_share() { + let opts = LayoutOptions { inset: 5.0, ..LayoutOptions::default() }; + let side = closed(&[&[(0.0, 0.0), (50.0, 0.0), (50.0, 100.0), (0.0, 100.0)], &[(50.0, 0.0), (100.0, 0.0), (100.0, 100.0), (50.0, 100.0)]]); + assert_spans(&cell_spans(&side, &opts, &[], 40.0, 44.0)[0], &[(5.0, 95.0)]); + let seam = closed(&[&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)], &[(0.0, 50.0), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)]]); + assert_spans(&cell_spans(&seam, &opts, &[], 46.0, 54.0)[0], &[(5.0, 95.0)]); + let slanted = + closed(&[&[(0.0, 0.0), (40.0, 0.0), (50.0, 50.0), (60.0, 100.0), (0.0, 100.0)], &[(40.0, 0.0), (100.0, 0.0), (100.0, 100.0), (60.0, 100.0)]]); + assert_spans(&cell_spans(&slanted, &opts, &[], 40.0, 44.0)[0], &[(5.0, 95.0)]); + // The lower contour covers only the left half of the upper one's bottom edge: the right half + // and the corner where they meet still keep the inset. + let step = closed(&[&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)], &[(0.0, 50.0), (50.0, 50.0), (50.0, 100.0), (0.0, 100.0)]]); + assert_spans(&cell_spans(&step, &opts, &[], 46.0, 48.0)[0], &[(5.0, 50.0 - 21f64.sqrt())]); +} + +/// A negative wrap offset does not shrink a wrap object beside an edge two of its contours share, +/// with or without Invert Wrap. +#[cfg(test)] +#[test] +fn negative_wrap_offset_does_not_shrink_beside_an_edge_two_contours_share() { + let left: &[(f64, f64)] = &[(40.0, 40.0), (50.0, 40.0), (50.0, 60.0), (40.0, 60.0)]; + let right: &[(f64, f64)] = &[(50.0, 40.0), (60.0, 40.0), (60.0, 60.0), (50.0, 60.0)]; + assert_spans(&wrap_spans(&[left, right], -5.0, false, 44.0, 50.0), &[(0.0, 45.0), (55.0, 100.0)]); + assert_spans(&wrap_spans(&[left, right], -5.0, true, 46.0, 54.0), &[(45.0, 55.0)]); +} + /// In a frame of columns, the inset is kept once from a column's inner edge and once from the /// frame's sloped edge. #[cfg(test)] diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index eae22bab8a..3a83a5673b 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -1173,11 +1173,14 @@ fn justification_adds_no_gap_inside_a_warichu_block_drawn_right_to_left() { let mut glyphs = synthetic_glyphs(&[(0, 2, 10.0, first), (2, 2, 0.0, second)]); block(&mut glyphs[0], 0, 0.0); block(&mut glyphs[1], 0, 10.0); - assert!(!flow::is_letter_gap(1, &[], &after, 0, &glyphs, false), "{first}{second}"); - assert!(!flow::is_cjk_gap(1, &[], &after, 0, &glyphs), "{first}{second}"); + // Glyph 0 is drawn after glyph 1 of its block. + let begun = [true, false]; + assert!(!flow::is_letter_gap(1, &[], (&after, &begun), 0, &glyphs, false), "{first}{second}"); + assert!(!flow::is_cjk_gap(1, &[], (&after, &begun), 0, &glyphs), "{first}{second}"); // The same two glyphs as the first glyphs of two blocks. block(&mut glyphs[1], 2, 0.0); - assert!(flow::is_letter_gap(1, &[], &after, 0, &glyphs, false), "{first}{second}"); - assert_eq!(flow::is_cjk_gap(1, &[], &after, 0, &glyphs), first == '漢', "{first}{second}"); + let begun = [false, false]; + assert!(flow::is_letter_gap(1, &[], (&after, &begun), 0, &glyphs, false), "{first}{second}"); + assert_eq!(flow::is_cjk_gap(1, &[], (&after, &begun), 0, &glyphs), first == '漢', "{first}{second}"); } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 6ae85931ca..362a1c2408 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -556,25 +556,32 @@ fn cluster_extent(layout: &TextLayout, glyphs: &[PositionedGlyph], i: usize) -> /// Caret for byte offset `byte`, as a (top, bottom) segment in text space. In horizontal type, a /// cluster drawn with several glyphs is measured whole ([`cluster_extent`]). Inside a ligature the -/// caret stands at its caret stops ([`PositionedGlyph::carets`]). +/// caret stands at its caret stops ([`PositionedGlyph::carets`]). Next to a character of a warichu +/// block the caret spans the band of its block line, as its highlight does. pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) { let Some(line) = layout.lines.get(layout.line_of(byte)) else { return (Point::ZERO, Point::ZERO); }; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; - let (pos, angle) = if let Some(i) = glyphs.iter().position(|g| g.byte <= byte && g.byte + g.len > byte) { + let (pos, angle, glyph) = if let Some(i) = glyphs.iter().position(|g| g.byte <= byte && g.byte + g.len > byte) { let g = &glyphs[i]; let (origin, advance) = match cluster_extent(layout, glyphs, i) { Some((left, right)) => (Point::new(left, g.origin.y), right - left), None => (g.origin, g.advance), }; let off = g.caret_offset(byte, advance); - (origin + dir(g.angle) * if g.rtl { advance - off } else { off }, g.angle) + (origin + dir(g.angle) * if g.rtl { advance - off } else { off }, g.angle, Some(g)) } else if let Some(g) = glyphs.iter().max_by_key(|g| g.byte + g.len) { - (g.origin + dir(g.angle) * if g.rtl { 0.0 } else { g.advance }, g.angle) + (g.origin + dir(g.angle) * if g.rtl { 0.0 } else { g.advance }, g.angle, Some(g)) } else { - (layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 }) + (layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 }, None) }; + if let Some((_, top, bottom)) = + glyph.filter(|g| g.warichu.is_some() && !layout.on_path).and_then(|g| block_band(&warichu_rows(layout, line), line, g)) + { + let x = layout.logical_point(pos).x; + return (layout.physical_point(Point::new(x, top)), layout.physical_point(Point::new(x, bottom))); + } // Up vector in y-down space, rotated with the baseline. let d = dir(angle); let up = Vec2::new(d.y, -d.x); @@ -786,6 +793,20 @@ fn warichu_rows(layout: &TextLayout, l: &LineInfo) -> Vec<(usize, f64, f64)> { rows } +/// The band across line `l` (text space, from the top) of the block line that warichu glyph `g` is +/// on, with that block line's index in its block: out to halfway to the block lines next to it, +/// and to the line's top or bottom past the block's outer lines. `rows` are the line's +/// [`warichu_rows`]. None for a glyph of no block. +fn block_band(rows: &[(usize, f64, f64)], l: &LineInfo, g: &PositionedGlyph) -> Option<(usize, f64, f64)> { + let block = g.warichu?; + let lines = rows.get(rows.partition_point(|r| r.0 < block)..rows.partition_point(|r| r.0 <= block)).unwrap_or_default(); + let row = lines.partition_point(|r| r.1 < g.warichu_line - 1e-9); + let own = lines.get(row).filter(|r| (r.1 - g.warichu_line).abs() < 1e-9)?; + let top = row.checked_sub(1).and_then(|k| lines.get(k)).map_or(l.baseline - l.ascent, |above| (above.2 + own.2) * 0.5); + let bottom = lines.get(row + 1).map_or(l.baseline + l.descent, |below| (below.2 + own.2) * 0.5); + Some((row, top, bottom)) +} + /// A glyph of a warichu block line, for [`selection_quads`]: its block and block line, its extent /// along the line and its band across it (text space), and whether the selection covers it. struct BlockPiece { @@ -829,11 +850,7 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 let mut pieces: Vec = Vec::new(); for (i, g) in layout.glyphs.iter().enumerate().take(l.glyph_end).skip(l.glyph_start) { let Some(block) = g.warichu else { continue }; - let lines = rows.get(rows.partition_point(|r| r.0 < block)..rows.partition_point(|r| r.0 <= block)).unwrap_or_default(); - let row = lines.partition_point(|r| r.1 < g.warichu_line - 1e-9); - let Some(own) = lines.get(row).filter(|r| (r.1 - g.warichu_line).abs() < 1e-9) else { continue }; - let top = row.checked_sub(1).and_then(|k| lines.get(k)).map_or(l.baseline - l.ascent, |above| (above.2 + own.2) * 0.5); - let bottom = lines.get(row + 1).map_or(l.baseline + l.descent, |below| (below.2 + own.2) * 0.5); + let Some((row, top, bottom)) = block_band(&rows, l, g) else { continue }; let x = layout.logical_point(g.origin).x; let selected = selected(i, g); let (f0, f1) = if selected { covered(g) } else { (0.0, g.advance) }; diff --git a/crates/text/src/tests/warichu.rs b/crates/text/src/tests/warichu.rs index d68cb8fccc..6aee052cbd 100644 --- a/crates/text/src/tests/warichu.rs +++ b/crates/text/src/tests/warichu.rs @@ -433,6 +433,51 @@ fn blocks_the_bidi_order_interleaves_keep_their_places() { assert!(c.x < bet.x && bet.x < z.x, "{c:?} {bet:?} {z:?}"); } +/// A justified line spreads its letters with the same gap before a right-to-left block as before a +/// left-to-right one: the block starts at the first of its glyphs the line draws. +#[test] +fn justification_opens_the_same_gap_before_a_right_to_left_block() { + for inside in ["cd", "אב"] { + let at = |justify: Justify| { + let mut t = area("", style(20.0), Rect::new(0.0, 0.0, 200.0, 400.0), justify); + t.runs = vec![TextRun::new("ab", style(20.0)), TextRun::new(inside, marked(20.0)), TextRun::new("z", style(20.0))]; + let l = layout(db(), &t); + let x = |byte: usize| l.glyphs.iter().filter(|g| g.byte == byte).map(|g| g.origin.x).fold(f64::INFINITY, f64::min); + let block = l.glyphs.iter().filter(|g| g.warichu.is_some()).map(|g| g.origin.x).fold(f64::INFINITY, f64::min); + (x(0), x(1), block, x(2 + inside.len())) + }; + let (a, b, block, z) = at(Justify::Left); + let (a2, b2, block2, z2) = at(Justify::JustifyAll); + let gap = (b2 - a2) - (b - a); + assert!(gap > 1.0, "{inside}: the line is spread: {gap}"); + assert!(((block2 - b2) - (block - b) - gap).abs() < 1e-6, "{inside}: the gap before the block: {} {gap}", (block2 - b2) - (block - b)); + assert!(((z2 - block2) - (z - block) - gap).abs() < 1e-6, "{inside}: the gap after the block: {} {gap}", (z2 - block2) - (z - block)); + } +} + +/// The caret next to a character of a block spans the band of the character's block line, not +/// the whole line. +#[test] +fn the_caret_in_a_block_spans_its_block_line() { + let l = layout(db(), &with_block("cdef", marked(20.0))); + let (c, e) = (&l.glyphs[2], &l.glyphs[4]); + let middle = (c.origin.y + e.origin.y) * 0.5; + let line = &l.lines[0]; + // "c" and "d", on the upper line. + for byte in [2, 3] { + let (top, bottom) = caret_position(&l, byte); + assert!((top.y - (line.baseline - line.ascent)).abs() < 1e-6 && (bottom.y - middle).abs() < 1e-6, "{byte}: {top:?} {bottom:?} {middle}"); + } + // "e" and "f", on the lower line. + for byte in [4, 5] { + let (top, bottom) = caret_position(&l, byte); + assert!((top.y - middle).abs() < 1e-6 && (bottom.y - (line.baseline + line.descent)).abs() < 1e-6, "{byte}: {top:?} {bottom:?} {middle}"); + } + // Outside the block the caret spans the line. + let (top, bottom) = caret_position(&l, 0); + assert!((top.y - (line.baseline - line.ascent)).abs() < 1e-6 && (bottom.y - (line.baseline + line.descent)).abs() < 1e-6); +} + /// A click on a block's lower line goes to that line's characters, not to the character of the /// upper line at the same place along the block. #[test] diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 2aa218e558..f3fda46685 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -3,7 +3,7 @@ use vectorcraft_geom::kurbo::ParamCurveDeriv; use super::outline::{self, Along, Budget, CURVE_TOLERANCE}; use super::rings::{ CURVE_PIECES, MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, Ring, Span, TOO_COMPLEX_SURFACE, curve_pieces, inset, left, signed_area, - solid_rings_traced, + solid_rings_traced, square_corners, }; use super::{ Bevel, DepthPlane, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, PlanarFace, Point, Rect, Revolve, V3, View, @@ -458,10 +458,15 @@ pub(super) fn check_bounds(b: Rect) -> Result<(), String> { } /// The art's outward direction at edge `i` of `ring` (before rotation), tilted towards the face -/// it borders by `z` (+1 front, -1 back, 0 a side wall). +/// it borders by `z` (+1 front, -1 back, 0 a side wall). An edge of no length (a corner cut +/// square, see [`square_corners`]) faces out between the edges on either side of it. pub(super) fn outward(ring: &[Point], i: usize, z: f64) -> V3 { let n = ring.len(); - let (lx, ly) = left(ring[i], ring[(i + 1) % n]); + let (mut lx, mut ly) = left(ring[i], ring[(i + 1) % n]); + if lx == 0.0 && ly == 0.0 && n > 2 { + let (a, b) = (left(ring[(i + n - 1) % n], ring[i]), left(ring[(i + 1) % n], ring[(i + 2) % n])); + (lx, ly) = (a.0 + b.0, a.1 + b.1); + } V3(-lx, -ly, z) } @@ -607,7 +612,10 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio let (bands, caps) = (steps.len().saturating_sub(1), if o.cap { 2 } else { 0 }); let size = bounds.width().max(bounds.height()); let most = curve_pieces(path, size, bands, caps)?; - let traced = solid_rings_traced(path, rule, size, MAX_SOLID_POINTS, most)?; + // Every inset of one extrusion goes the same way: into the shape for a bevel inside, out of it + // for one outside. The corners that way opens are cut square past the miter limit. + let side = steps.iter().map(|s| s.inset).find(|w| *w != 0.0).unwrap_or(0.0); + let traced: Vec = solid_rings_traced(path, rule, size, MAX_SOLID_POINTS, most)?.iter().map(|r| square_corners(r, side)).collect(); let rings: Vec<&[Point]> = traced.iter().map(|r| r.points.as_slice()).collect(); let edges = rings.iter().map(|r| r.len()).fold(0usize, usize::saturating_add); if edges.saturating_mul(bands).saturating_add(caps) > MAX_SOLID_FACES { diff --git a/crates/three-d/src/solid/outline.rs b/crates/three-d/src/solid/outline.rs index 07fb0ce58b..93d9d70340 100644 --- a/crates/three-d/src/solid/outline.rs +++ b/crates/three-d/src/solid/outline.rs @@ -123,7 +123,8 @@ fn contour(path: &mut BezPath, ring: &Ring, points: &[Point], step: &Along, budg while i < n { let end = |k: usize| points.get((k + 1) % n).copied().unwrap_or(first); let Some(span) = ring.spans.get(i).copied().flatten() else { - if i + 1 < n { + // A corner cut square (see `square_corners`) whose two copies did not move apart. + if i + 1 < n && points.get(i).copied() != Some(end(i)) { path.line_to(end(i)); } i += 1; diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 80dc66407c..882bd245af 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -16,6 +16,10 @@ pub(super) const CURVE_PIECES: u32 = 32; /// for fewer takes fewer. pub(super) const MIN_CURVE_PIECES: u32 = 4; +/// How far an inset corner's miter reaches at most, as a multiple of the inset. [`square_corners`] +/// cuts a corner whose miter would reach further square across instead. +const MITER_LIMIT: f64 = 4.0; + const TOO_COMPLEX_SHAPE: &str = "The shape is too complex for a 3D effect"; /// The error a solid past its limits returns, and a 3D effect whose solids together pass /// [`crate::MAX_OBJECT_FACES`]. @@ -472,8 +476,10 @@ pub(super) fn left(a: Point, b: Point) -> (f64, f64) { } /// `ring` moved `w` points towards its filled side (negative: away from it), each corner along -/// its bisector, its mitre limited to four times `w` so sharp corners don't shoot off. Each point -/// stays the partner of its source point, so rings at different insets join face to face. +/// its bisector, its miter limited to [`MITER_LIMIT`] times `w` so sharp corners don't shoot off. +/// A point given twice (a corner [`square_corners`] cut) moves each copy along its one edge's +/// normal. Each point stays the partner of its source point, so rings at different insets join +/// face to face. pub(super) fn inset(ring: &[Point], w: f64) -> Vec { if w == 0.0 { return ring.to_vec(); @@ -486,6 +492,12 @@ pub(super) fn inset(ring: &[Point], w: f64) -> Vec { let next = ring[(i + 1) % n]; let (ax, ay) = left(prev, p); let (bx, by) = left(p, next); + if ax == 0.0 && ay == 0.0 { + return Point::new(p.x + bx * w, p.y + by * w); + } + if bx == 0.0 && by == 0.0 { + return Point::new(p.x + ax * w, p.y + ay * w); + } let (mut mx, mut my) = (ax + bx, ay + by); let ml = mx.hypot(my); if ml < 1e-9 { @@ -493,8 +505,43 @@ pub(super) fn inset(ring: &[Point], w: f64) -> Vec { } else { (mx, my) = (mx / ml, my / ml); } - let cos = (mx * ax + my * ay).max(0.25); + let cos = (mx * ax + my * ay).max(1.0 / MITER_LIMIT); Point::new(p.x + mx * w / cos, p.y + my * w / cos) }) .collect() } + +/// `ring` with each corner whose miter an inset towards `side` (positive: the filled side, +/// negative: away from it) would reach past [`MITER_LIMIT`] given twice, joined by an edge of no +/// length that follows no curve. [`inset`] moves the two copies apart along their own edges' +/// normals, so the inset ring cuts the corner square, as a bevel join does, instead of running out +/// to a point along the bisector. Corners on the other side of the turn, whose inset edges meet +/// before the corner, keep one point. +pub(super) fn square_corners(ring: &Ring, side: f64) -> Ring { + let n = ring.points.len(); + if side == 0.0 || n < 3 || ring.spans.len() != n { + return ring.clone(); + } + let mut out = Ring { points: Vec::with_capacity(n), spans: Vec::with_capacity(n) }; + for (i, (&p, span)) in ring.points.iter().zip(&ring.spans).enumerate() { + let prev = ring.points[(i + n - 1) % n]; + let next = ring.points[(i + 1) % n]; + let (a, b) = (left(prev, p), left(p, next)); + let (mx, my) = (a.0 + b.0, a.1 + b.1); + let ml = mx.hypot(my); + // The cosine of the half angle between the bisector and each edge's normal: the miter + // reaches 1 / cos times the inset. + let cos = if ml < 1e-9 { 0.0 } else { (mx * a.0 + my * a.1) / ml }; + // Which way the outline turns at `p`: an inset towards `side` opens the corner when the + // turn is against it. + let turn = (p - prev).cross(next - p); + let edges = (a.0 != 0.0 || a.1 != 0.0) && (b.0 != 0.0 || b.1 != 0.0); + out.points.push(p); + if edges && cos < 1.0 / MITER_LIMIT && turn * side < 0.0 { + out.spans.push(None); + out.points.push(p); + } + out.spans.push(*span); + } + out +} diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 335f434419..7afce495ad 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -255,6 +255,78 @@ fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() { assert!(inside.iter().any(|f| !close(f.brightness, inside[inside.len() - 1].brightness, 1e-3))); } +/// A bevel past a sharp corner is cut square, as a bevel join cuts a stroke, instead of running +/// out to a spike along the corner's bisector: seen straight on, no point of the solid lies further +/// from the art than the miters of its corners of about 90° (14.6 pt for a 10 pt bevel), where the +/// sharp corner's spike reached 40 pt. The bevel opens the corner of a thin spike (with its +/// neighbor in the same compound path) when it is outside, and of a narrow notch when it is inside; +/// the other two cases, where the inset edges meet, keep their limited miters and are checked too. +#[test] +fn a_bevel_cuts_sharp_corners_square() { + let poly = |pts: &[(f64, f64)]| { + let mut b = BezPath::new(); + b.move_to(pts[0]); + pts[1..].iter().for_each(|p| b.line_to(*p)); + b.close_path(); + b + }; + let spike = [(50.0, 0.0), (56.0, 100.0), (44.0, 100.0)]; + let block = [(80.0, 40.0), (120.0, 40.0), (120.0, 100.0), (80.0, 100.0)]; + let notched = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (52.0, 100.0), (50.0, 20.0), (48.0, 100.0), (0.0, 100.0)]; + let mut both = poly(&spike); + both.extend(poly(&block).elements().iter().copied()); + let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() }; + let mut far_cases = vec![]; + for (name, art, outline) in [("spike", both, vec![spike.to_vec(), block.to_vec()]), ("notch", poly(¬ched), vec![notched.to_vec()])] { + let (p, b) = shape(&art); + let outline: Vec> = outline.iter().map(|r| r.iter().map(|q| Point::new(q.0, q.1)).collect()).collect(); + for inside in [false, true] { + for shape in [BevelShape::Classic, BevelShape::Rounded] { + let o = Extrude { bevel: Some(Bevel { inside, shape, ..bevel }), ..Extrude::default() }; + let f = extrude(&p, FillRule::NonZero, b, o, front()).unwrap(); + let far = f.iter().flat_map(|f| f.contours.iter().flatten()).map(|q| edge_distance(&outline, *q)).fold(0.0, f64::max); + if far > 15.0 { + far_cases.push(format!("{name} inside {inside} {shape:?}: a point {far} pt from the art")); + } + assert!(f.iter().all(|f| f.normal.iter().all(|v| v.is_finite())), "{name} inside {inside} {shape:?}"); + } + // Turned, every face still has a normal it can be lit and sorted by. + let o = Extrude { bevel: Some(Bevel { inside, ..bevel }), ..Extrude::default() }; + let f = extrude(&p, FillRule::NonZero, b, o, View::default()).unwrap(); + assert!(f.iter().all(|f| f.normal.iter().all(|v| v.is_finite()) && f.brightness.is_finite()), "{name} inside {inside}"); + } + } + assert!(far_cases.is_empty(), "{far_cases:#?}"); +} + +/// With the bevel outside, the cap is the outline itself, so a corner cut square keeps its two +/// points together there: the cap's outline draws no straight segment of no length at that corner. +#[test] +fn a_cap_draws_no_empty_segment_at_a_corner_cut_square() { + use vectorcraft_geom::kurbo::PathEl; + let mut art = BezPath::new(); + art.move_to((50.0, 0.0)); + art.line_to((56.0, 100.0)); + art.curve_to((56.0, 120.0), (44.0, 120.0), (44.0, 100.0)); + art.close_path(); + let (p, b) = shape(&art); + let o = Extrude { bevel: Some(Bevel { width: 10.0, height: 10.0, ..Bevel::default() }), ..Extrude::default() }; + let f = extrude(&p, FillRule::NonZero, b, o, front()).unwrap(); + let cap = f.iter().find(|f| f.depth == f64::MAX && f.outline.is_some()).unwrap(); + let mut at = None; + for el in cap.path().to_bezpath().elements() { + match *el { + PathEl::MoveTo(q) => at = Some(q), + PathEl::LineTo(q) => { + assert_ne!(at, Some(q), "a segment of no length at {q:?}"); + at = Some(q); + } + PathEl::QuadTo(_, q) | PathEl::CurveTo(_, _, q) => at = Some(q), + PathEl::ClosePath => {} + } + } +} + #[test] fn bevel_shapes_repeats_and_both_sides_change_the_surface() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); diff --git a/crates/tools/src/symbolism.rs b/crates/tools/src/symbolism.rs index 1618d6e7d6..580e61d4fd 100644 --- a/crates/tools/src/symbolism.rs +++ b/crates/tools/src/symbolism.rs @@ -238,10 +238,15 @@ impl Default for BrushPaintTool { } impl BrushPaintTool { + /// The tool's brush, else the document's current brush, skipping a name the document's brush + /// library no longer has (a deleted brush), so the stroke is drawn without it. fn brush_name(&self, cx: &ToolContext) -> Option { - self.brush.clone().or_else(|| cx.doc.unknown.get("currentBrush").and_then(Value::as_str).map(str::to_string)) + [self.brush.clone(), vectorcraft_brush::current(cx.doc)].into_iter().flatten().find(|n| vectorcraft_brush::find(cx.doc, n).is_some()) } fn map(&self, cx: &ToolContext, acts: Vec) -> Vec { + if !acts.iter().any(|a| matches!(a, Action::Preview(c, _) if c == "path.freehand")) { + return acts; + } let Some(b) = self.brush_name(cx) else { return acts }; acts.into_iter() .map(|a| match a { @@ -403,4 +408,23 @@ mod tests { t.set_option("brush", &json!("Dots")); assert_eq!(t.options()["brush"], "Dots"); } + + #[test] + fn paintbrush_skips_a_brush_the_document_does_not_have() { + let (mut d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let drag = |d: &vectorcraft_doc::Document, t: &mut BrushPaintTool| { + let cx = cx(d, &s, &p); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 30.0, 0.0)).swap_remove(1) + }; + d.unknown.insert("currentBrush".into(), json!("Charcoal")); + let mut t = BrushPaintTool::default(); + t.set_option("brush", &json!("Deleted")); + let a = drag(&d, &mut t); + assert!(matches!(&a, Action::Preview(c, v) if c == "brush.freehand" && v["brush"] == "Charcoal"), "the current brush: {a:?}"); + d.unknown.insert("currentBrush".into(), json!("Also deleted")); + let a = drag(&d, &mut BrushPaintTool::default()); + assert!(matches!(&a, Action::Preview(c, v) if c == "path.freehand" && v.get("brush").is_none()), "no brush: {a:?}"); + } } diff --git a/crates/trace/src/logo/pipeline.rs b/crates/trace/src/logo/pipeline.rs index 766cf242dd..16bb1b3b08 100644 --- a/crates/trace/src/logo/pipeline.rs +++ b/crates/trace/src/logo/pipeline.rs @@ -6,7 +6,7 @@ use super::march::{area2, contours}; use super::outline::{Stats, Style, fit_outline}; use super::palette::{WHITE_MIN, auto_palette, flat_fraction, fold_tones, parse_hex, unexplained_colours}; use super::restore::RestoreParams; -use super::unmix::{Px, count_ambiguous, unmix}; +use super::unmix::{Px, Switched, count_ambiguous, unmix}; use crate::{Raster, TraceError, TraceParams, TraceResult, TracedPath}; use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath}; @@ -20,7 +20,7 @@ const FIELD_CELLS_MAX: usize = 6_000_000; const MIN_FLAT: f64 = 0.93; /// Most outlines of one colour (above the noise threshold) we will fit; more is refused, not truncated. const MAX_LOOPS_PER_CLASS: usize = if cfg!(test) { 500 } else { 20_000 }; -/// Most distinct pixel values `unmix` may have to work on (those that are not exactly one color). A busier image is +/// Most distinct pixel values `unmix` may have to work on (`count_ambiguous`). A busier image is /// averaged down further first, which bounds the time on pathological input. const EDGE_BUDGET: usize = 150_000; /// Palette colours closer than this (RGB distance) are one colour. @@ -320,27 +320,33 @@ fn keep_shape(s: &Shape, map: &ToSource, kept: &mut Vec, anchors_made: &mu /// A hard-edged source (a GIF, say) has no anti-aliasing to read an edge position from, and its /// outlines would follow the pixel staircase. Blurring the coverage by under a pixel gives the -/// outline a smooth edge to find. Sources with plenty of blended edge pixels are left alone. -fn soften_if_aliased(planes: &mut [Vec], w: usize, h: usize) -> bool { +/// outline a smooth edge to find. Sources with plenty of blended edge pixels are left alone. The +/// pixels that took another mix than their best one (`switched`) are judged by their best mix. +fn soften_if_aliased(planes: &mut [Vec], switched: &Switched, w: usize, h: usize) -> bool { let n = w * h; - let dominant: Vec = - (0..n).map(|i| (0..planes.len()).max_by(|&a, &b| plane_at(planes, a, i).total_cmp(&plane_at(planes, b, i))).unwrap_or(0)).collect(); - let (mut boundary, mut blended) = (0usize, 0usize); - for y in 0..h { - for x in 0..w { - let i = y * w + x; - let d = dominant.get(i).copied().unwrap_or(0); - let strongest = (0..planes.len()).map(|c| plane_at(planes, c, i)).fold(0.0f32, f32::max); - if strongest < 0.9 { - blended += 1; - } - let differs = |j: usize| dominant.get(j).is_some_and(|&o| o != d); - if (x + 1 < w && differs(i + 1)) || (y + 1 < h && differs(i + w)) { - boundary += 1; + let aliased = { + let at = |c: usize, i: usize| match switched.binary_search_by_key(&i, |&(j, _)| j) { + Ok(s) => switched.get(s).and_then(|(_, col)| col.get(c)).copied().unwrap_or(0.0), + Err(_) => plane_at(planes, c, i), + }; + let dominant: Vec = (0..n).map(|i| (0..planes.len()).max_by(|&a, &b| at(a, i).total_cmp(&at(b, i))).unwrap_or(0)).collect(); + let (mut boundary, mut blended) = (0usize, 0usize); + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let d = dominant.get(i).copied().unwrap_or(0); + let strongest = (0..planes.len()).map(|c| at(c, i)).fold(0.0f32, f32::max); + if strongest < 0.9 { + blended += 1; + } + let differs = |j: usize| dominant.get(j).is_some_and(|&o| o != d); + if (x + 1 < w && differs(i + 1)) || (y + 1 < h && differs(i + w)) { + boundary += 1; + } } } - } - let aliased = boundary > 0 && blended * 4 < boundary; + boundary > 0 && blended * 4 < boundary + }; if aliased { for p in planes.iter_mut() { blur_plane(p, w, h, 0.8); @@ -437,26 +443,27 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) let mut classes = classes_for(&palette, own_white || merge_white.is_some()); // A very busy image is averaged down further until the unmixing work is bounded. for _ in 0..6 { - if count_ambiguous(&pix, &classes) <= EDGE_BUDGET || w * h <= 16_384 { + if count_ambiguous(&pix, w, h, &classes) <= EDGE_BUDGET || w * h <= 16_384 { break; } (pix, w, h, factor) = working_copy(img, factor * 2); } - let (mut planes, residual) = unmix(&pix, w, h, &classes); - let mut planes = if params.logo_colors.is_empty() && palette.len() < MAX_COLOURS { + let (mut planes, residual, switched) = unmix(&pix, w, h, &classes); + let (mut planes, mut switched) = if params.logo_colors.is_empty() && palette.len() < MAX_COLOURS { // Colours too small to be found at first show up as pixels nothing explains: add them. let extra = unexplained_colours(&pix, w, h, &residual, &palette); if extra.is_empty() { - planes + (planes, switched) } else { palette.extend(extra.into_iter().take(MAX_COLOURS - palette.len())); // (A colour nothing explained may be one more tone of a colour that is there.) palette = fold_tones(&pix, palette); classes = classes_for(&palette, own_white); - unmix(&pix, w, h, &classes).0 + let (planes, _, switched) = unmix(&pix, w, h, &classes); + (planes, switched) } } else { - std::mem::take(&mut planes) + (std::mem::take(&mut planes), switched) }; if let Some(to) = merge_white && palette.len() < planes.len() @@ -467,10 +474,18 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) *t += *v; } } + for (_, col) in &mut switched { + if palette.len() < col.len() { + let white_weight = col.remove(palette.len()); + if let Some(t) = col.get_mut(to) { + *t += white_weight; + } + } + } } // Without a white class, `white` is an index no class has. let (white, transparent) = if own_white { (palette.len(), palette.len() + 1) } else { (usize::MAX, palette.len()) }; - let aliased = soften_if_aliased(&mut planes, w, h); + let aliased = soften_if_aliased(&mut planes, &switched, w, h); if own_white { fill_knockouts(&mut planes, w, h, white, transparent); } diff --git a/crates/trace/src/logo/tests.rs b/crates/trace/src/logo/tests.rs index 168ba05d7f..33ab678a1f 100644 --- a/crates/trace/src/logo/tests.rs +++ b/crates/trace/src/logo/tests.rs @@ -919,3 +919,242 @@ fn an_outline_fitted_without_area_is_left_out() { } } } + +/// A black disc and a gray square on white, the gray lying on the line from black to white: the +/// disc's edge pixels are blends of black and white, so the gray is one shape (the square) and no +/// gray lines the disc. Colors found and named exactly, the darker gray also named 52 levels off; +/// sharp and soft edges. +#[test] +fn a_gray_between_black_and_white_is_one_shape() { + const INK: [u8; 3] = [4, 4, 3]; + for gray in [[230u8, 230, 230], [200, 200, 200]] { + for blur in [0.0, 0.6] { + let img = render(120, 60, blur, |x, y| { + Some(if (x - 30.0).hypot(y - 30.0) <= 20.0 { + INK + } else if (60.0..=110.0).contains(&x) && (10.0..=50.0).contains(&y) { + gray + } else { + WHITE + }) + }); + // (A gray of 230 named 52 levels darker lies nearer white than its name, and is traced as white.) + let offs: &[Option] = if gray[0] == 200 { &[None, Some(0), Some(52)] } else { &[None, Some(0)] }; + for &by in offs { + let (p, gray_name) = match by { + None => (logo_params(), gray), + Some(by) => { + // Each name moved `by` levels toward mid-gray: the black lighter, the gray darker. + let names = [INK.map(|v| v.saturating_add(by)), gray.map(|v| v.saturating_sub(by))]; + let logo_colors = names.iter().map(|c| format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2])).collect(); + (TraceParams { logo_colors, ..logo_params() }, names[1]) + } + }; + let what = format!("gray {gray:?}, blur {blur}, names moved {by:?}"); + let res = trace(&img, &p); + let grays = res.paths.iter().filter(|t| t.color == gray_name).count(); + let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect(); + assert_eq!(grays, 1, "{what}: {grays} gray paths; {paths:?}"); + assert_eq!(top_color(&res, Point::new(85.0, 30.0)), Some(gray_name), "{what}: the square"); + for k in 0..16 { + let a = f64::from(k) * std::f64::consts::TAU / 16.0; + let at = Point::new(30.0 + 20.0 * a.cos(), 30.0 + 20.0 * a.sin()); + assert_ne!(top_color(&res, at), Some(gray_name), "{what}: the disc's edge at {at:?}"); + } + } + } + } +} + +/// A one-pixel gray line between flat black and flat white, with the three colors named, stays a +/// gray line along its whole length, beside a black disc whose edge pixels are as light as the gray. +#[test] +fn a_one_pixel_gray_line_between_black_and_white_stays_gray() { + const INK: [u8; 3] = [4, 4, 3]; + for gray in [[230u8, 230, 230], [200, 200, 200], [128, 128, 128]] { + let img = render(120, 80, 0.0, |x, y| { + Some(if x < 40.0 || (x - 90.0).hypot(y - 40.0) <= 15.0 { + INK + } else if x < 41.0 { + gray + } else { + WHITE + }) + }); + let logo_colors = vec!["#040403".into(), format!("#{:02X}{:02X}{:02X}", gray[0], gray[1], gray[2]), "#FFFFFF".into()]; + let res = trace(&img, &TraceParams { logo_colors, ..logo_params() }); + let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect(); + for y in 2..78 { + let at = Point::new(40.5, f64::from(y) + 0.5); + assert_eq!(top_color(&res, at), Some(gray), "gray {gray:?}: the line at {at:?}; {paths:?}"); + } + for k in 0..16 { + let a = f64::from(k) * std::f64::consts::TAU / 16.0; + let at = Point::new(90.0 + 15.0 * a.cos(), 40.0 + 15.0 * a.sin()); + assert_ne!(top_color(&res, at), Some(gray), "gray {gray:?}: the disc's edge at {at:?}"); + } + } +} + +/// A gray stroke 12 pixels long touching a black disc on white, with the three colors named: the +/// stroke's pixels join the disc's edge pixels that are as light as the gray, but no gray lines the +/// rest of the disc's edge, and a sharp stroke stays gray. Strokes two and three pixels wide outside +/// the disc, on and off the pixel grid, sharp and soft. A soft stroke about two pixels wide that +/// touches black is traced as black and white, and one about three pixels wide in part, so the +/// stroke's own color is checked on sharp strokes. +#[test] +fn a_gray_stroke_touching_a_black_disc_leaves_its_edge_black_and_white() { + const INK: [u8; 3] = [4, 4, 3]; + for gray in [[230u8, 230, 230], [200, 200, 200], [128, 128, 128]] { + for blur in [0.0, 0.6] { + // The disc reaches x = 42 to 43 along the stroke's rows; each stroke starts inside it. + for (sx, wide) in [(41.0, 4.0), (41.5, 4.0), (41.0, 5.0)] { + let img = render(120, 60, blur, |x, y| { + Some(if (x - 25.0).hypot(y - 28.0) <= 18.0 { + INK + } else if (sx..sx + wide).contains(&x) && (22.0..34.0).contains(&y) { + gray + } else { + WHITE + }) + }); + let logo_colors = vec!["#040403".into(), format!("#{:02X}{:02X}{:02X}", gray[0], gray[1], gray[2]), "#FFFFFF".into()]; + let res = trace(&img, &TraceParams { logo_colors, ..logo_params() }); + let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect(); + let what = format!("gray {gray:?}, blur {blur}, stroke at {sx} {wide} px wide"); + if blur == 0.0 { + for y in 24..32 { + let at = Point::new(sx + wide - 1.0, f64::from(y) + 0.5); + assert_eq!(top_color(&res, at), Some(gray), "{what}: the stroke at {at:?}; {paths:?}"); + } + } + // Points on the disc's edge more than 30 degrees from the stroke (which spans about 20). + for k in 6..=58 { + let a = f64::from(k) * std::f64::consts::TAU / 64.0; + let at = Point::new(25.0 + 18.0 * a.cos(), 28.0 + 18.0 * a.sin()); + assert_ne!(top_color(&res, at), Some(gray), "{what}: the disc's edge at {at:?}; {paths:?}"); + } + } + } + } +} + +/// Stripes on white (black, a dark gray with a black disc on it, a light gray), with the colors +/// named 52 levels off: each gray then lies on the line between two other names, and the dark +/// stripe's rows beside white keep the stripe's color above and below the disc. Sharp and soft edges, +/// with and without white named. +#[test] +fn a_gray_stripe_beside_white_keeps_its_color_beside_a_black_disc() { + const INK: [u8; 3] = [5, 5, 3]; + for blur in [0.0, 0.6] { + let img = render(300, 160, blur, |x, y| { + let inside = |x0: f64, y0: f64, x1: f64, y1: f64| (x0..x1).contains(&x) && (y0..y1).contains(&y); + Some(if (x - 150.0).hypot(y - 80.0) <= 12.0 || inside(20.0, 20.0, 280.0, 50.0) { + INK + } else if inside(20.0, 65.0, 280.0, 95.0) { + [100, 100, 100] + } else if inside(20.0, 110.0, 280.0, 140.0) { + [190, 190, 190] + } else { + WHITE + }) + }); + for white in [false, true] { + let mut logo_colors: Vec = ["#393937", "#989898", "#8A8A8A"].iter().map(|s| (*s).to_string()).collect(); + if white { + logo_colors.push("#FFFFFF".into()); + } + let res = trace(&img, &TraceParams { logo_colors, ..logo_params() }); + let what = format!("blur {blur}, white named {white}"); + let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect(); + let stripe = top_color(&res, Point::new(60.0, 80.0)); + assert_ne!(stripe, Some([0x39, 0x39, 0x37]), "{what}: the stripe; {paths:?}"); + for x in [140.0, 145.0, 150.0, 155.0, 160.0] { + for y in [65.5, 94.5] { + assert_eq!(top_color(&res, Point::new(x, y)), stripe, "{what}: ({x}, {y}); {paths:?}"); + } + } + if blur == 0.0 { + let inks = res.paths.iter().filter(|t| t.color == [0x39, 0x39, 0x37]).count(); + assert_eq!(inks, 2, "{what}: {inks} paths in the black's name; {paths:?}"); + } + } + } +} + +/// Gray strokes two and three pixels wide on white (whole pixels of the gray), 3 and 4 px from a +/// black disc, and a two-pixel stroke six pixels long, fewer pixels than a line needs to keep its +/// class, 2 to 4.5 px from the disc: the strokes' pixels are blends of black and white too, but nothing +/// darker than the gray lies beside them, and they stay gray. +#[test] +fn thin_gray_strokes_beside_black_stay_gray() { + const INK: [u8; 3] = [4, 4, 3]; + for gray in [[230u8, 230, 230], [200, 200, 200]] { + for wide in [2.0, 3.0] { + let img = render(120, 60, 0.0, |x, y| { + Some(if (x - 25.0).hypot(y - 28.0) <= 18.0 { + INK + } else if ((47.0..47.0 + wide).contains(&x) && (8.0..50.0).contains(&y)) + || ((8.0..110.0).contains(&x) && (49.0..49.0 + wide).contains(&y)) + || ((44.0..46.0).contains(&x) && (16.0..22.0).contains(&y)) + { + gray + } else { + WHITE + }) + }); + let logo_colors = vec!["#040403".into(), format!("#{:02X}{:02X}{:02X}", gray[0], gray[1], gray[2])]; + let res = trace(&img, &TraceParams { logo_colors, ..logo_params() }); + let what = format!("gray {gray:?}, {wide} px strokes"); + let short = (17..21).map(|y| Point::new(45.0, f64::from(y) + 0.5)); + let long = [Point::new(47.0 + wide / 2.0, 20.0), Point::new(47.0 + wide / 2.0, 30.0), Point::new(25.0, 49.0 + wide / 2.0)]; + for at in long.into_iter().chain(short) { + let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect(); + assert_eq!(top_color(&res, at), Some(gray), "{what}: {at:?}; {paths:?}"); + } + } + } +} + +/// A sharp black disc and a mid-gray square on white, with up to 2 levels of noise added to each +/// pixel: the edge pixels as light as the gray, read as blends of black and white, still count as +/// unblended when judging whether the source is aliased, so the trace is softened as without them +/// and no gray lines the disc. +#[test] +fn a_noisy_hard_edged_gray_between_black_and_white_is_one_shape() { + const GRAY: [u8; 3] = [128, 128, 128]; + let mut img = render(120, 60, 0.0, |x, y| { + Some(if (x - 30.0).hypot(y - 30.0) <= 20.0 { + [0, 0, 0] + } else if (60.0..=110.0).contains(&x) && (10.0..=50.0).contains(&y) { + GRAY + } else { + WHITE + }) + }); + for (i, px) in img.rgba.chunks_mut(4).enumerate() { + let mut h = (i as u64 ^ 0x9E37_79B9_7F4A_7C15).wrapping_mul(0x2545_F491_4F6C_DD1D); + h ^= h >> 29; + let d = (h % 5) as i32 - 2; + for c in px.iter_mut().take(3) { + *c = (i32::from(*c) + d).clamp(0, 255) as u8; + } + } + for named in [false, true] { + let p = if named { + TraceParams { logo_colors: vec!["#000000".into(), "#808080".into(), "#FFFFFF".into()], ..logo_params() } + } else { + logo_params() + }; + let res = trace(&img, &p); + let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect(); + let grays = res.paths.iter().filter(|t| t.color == GRAY).count(); + assert_eq!(grays, 1, "named {named}: {grays} gray paths; {paths:?}"); + assert_eq!(top_color(&res, Point::new(85.0, 30.0)), Some(GRAY), "named {named}: the square"); + for k in 0..16 { + let a = f64::from(k) * std::f64::consts::TAU / 16.0; + let at = Point::new(30.0 + 20.0 * a.cos(), 30.0 + 20.0 * a.sin()); + assert_ne!(top_color(&res, at), Some(GRAY), "named {named}: the disc's edge at {at:?}; {paths:?}"); + } + } +} diff --git a/crates/trace/src/logo/unmix.rs b/crates/trace/src/logo/unmix.rs index cdfdfe52fd..3268c9c20a 100644 --- a/crates/trace/src/logo/unmix.rs +++ b/crates/trace/src/logo/unmix.rs @@ -3,7 +3,10 @@ //! An edge pixel of an anti-aliased logo is a blend of the colours that meet there. Reading it as //! "the nearest colour" throws away exactly the sub-pixel information that says where the edge //! runs, so each pixel is instead explained as a mix of at most three classes, whichever mix -//! reproduces the pixel best. +//! reproduces the pixel best. When another mix also reproduces it (with a gray between black and +//! white among the classes, a black-to-white edge pixel is a mix of black and gray as well as of +//! black and white), the pixel takes the mix whose classes lie nearest it in the flat areas around it, +//! when its neighbors show an edge between those classes. use std::collections::{HashMap, HashSet}; @@ -13,6 +16,20 @@ pub(crate) type Px = [f32; 4]; const LAMBDA: f64 = 30.0; /// Pixels this close (in 0..1 premultiplied RGBA) to a class are taken as exactly that class. const PURE: f64 = 4.0 / 255.0; +/// A mix whose squared error is at most this reproduces the pixel: it comes within [`PURE`] of it, +/// as a pixel taken as exactly one class does. +const REPRODUCES: f64 = PURE * PURE; +/// How far (in pixels, each way) a class is looked for around a pixel that two mixes reproduce. +const NEARBY: usize = 6; +/// A pixel whose neighbors are all this close to it (in 0..1 premultiplied RGBA) lies in a flat area. +const FLAT: f64 = 1.5 / 255.0; +/// How many joined pixels within [`PURE`] of one class are taken as art of that class, such as a +/// one-pixel line. The edge pixels of a curve that come within `PURE` of a class join in far fewer. +/// A straight edge whose anti-aliased pixels come within `PURE` of +/// a class gives the same pixels as a line of that class, and keeps the class too. A one-pixel line +/// between two classes that is shorter, falls between pixels or is blurred is read as a blend of +/// those two classes. +const LINE: usize = 2 * NEARBY + 1; struct Subset { idx: [usize; 3], @@ -90,21 +107,33 @@ fn subsets(classes: &[[f64; 4]]) -> Vec { out } -/// How many distinct pixel values are not (nearly) exactly one class: the ones `unmix` has to work -/// on. A flat area whose color is near but not exactly a class counts once, however large it is. -pub(crate) fn count_ambiguous(pix: &[Px], classes: &[[f64; 4]]) -> usize { +/// How many distinct pixel values `unmix` has to work on: the values not (nearly) exactly one class, +/// and the values that are, at pixels outside a flat area ([`in_flat_area`]). A flat area whose color +/// is near but not exactly a class counts once, however large it is. +pub(crate) fn count_ambiguous(pix: &[Px], width: usize, height: usize, classes: &[[f64; 4]]) -> usize { let mut seen = HashSet::new(); - for px in pix { + for (i, px) in pix.iter().take(width.saturating_mul(height)).enumerate() { let p = [f64::from(px[0]), f64::from(px[1]), f64::from(px[2]), f64::from(px[3])]; - if !classes.iter().any(|cv| (0..4).map(|j| (p[j] - cv[j]).powi(2)).sum::().sqrt() < PURE) { - seen.insert(px.map(f32::to_bits)); + let key = px.map(f32::to_bits); + if seen.contains(&key) { + continue; + } + if !classes.iter().any(|cv| (0..4).map(|j| (p[j] - cv[j]).powi(2)).sum::().sqrt() < PURE) + || !in_flat_area(pix, width, height, i % width, i / width) + { + seen.insert(key); } } seen.len() } -/// Coverage planes, one `width × height` plane per class (each pixel's weights sum to 1). -pub(crate) fn unmix(pix: &[Px], width: usize, height: usize, classes: &[[f64; 4]]) -> (Vec>, Vec) { +/// The pixels that took another mix than their best one, in pixel order, each with every class's +/// weight before it did. +pub(crate) type Switched = Vec<(usize, Vec)>; + +/// Coverage planes, one `width × height` plane per class (each pixel's weights sum to 1), the +/// squared error each pixel's best mix leaves, and the pixels that took another mix ([`Switched`]). +pub(crate) fn unmix(pix: &[Px], width: usize, height: usize, classes: &[[f64; 4]]) -> (Vec>, Vec, Switched) { let k = classes.len(); let n = width.saturating_mul(height); let mut planes = vec![vec![0.0f32; n]; k]; @@ -113,46 +142,384 @@ pub(crate) fn unmix(pix: &[Px], width: usize, height: usize, classes: &[[f64; 4] let subs = subsets(classes); // Each distinct pixel value is mixed once: a flat area that is near but not exactly a class // repeats one value. - let mut mixed: HashMap<[u32; 4], Mix> = HashMap::new(); + let mut mixed: HashMap<[u32; 4], Mixes> = HashMap::new(); + // The class each pixel stands for (the class nearest it, when every other class is at least + // twice as far). + let mut stands_for: Vec> = vec![None; n]; + // The class each pixel comes within `PURE` of, where it does. + let mut pure: Vec> = vec![None; n]; + // The pixels outside a flat area that another mix also reproduces, with their values. + let mut tied: Vec<(usize, [u32; 4])> = Vec::new(); for (i, px) in pix.iter().take(n).enumerate() { let p = [f64::from(px[0]), f64::from(px[1]), f64::from(px[2]), f64::from(px[3])]; // Fast path: a pixel that is (nearly) exactly one class. let mut near = (f64::MAX, 0usize); + let mut second = f64::MAX; for (c, cv) in classes.iter().enumerate() { let d: f64 = (0..4).map(|j| (p[j] - cv[j]).powi(2)).sum(); if d < near.0 { + second = near.0; near = (d, c); + } else if d < second { + second = d; } } + if let Some(q) = stands_for.get_mut(i) { + // (Squared distances: twice as far is four times the square.) + *q = (4.0 * near.0 <= second).then_some(near.1); + } + let key = px.map(f32::to_bits); if near.0.sqrt() < PURE { if let Some(v) = planes.get_mut(near.1).and_then(|pl| pl.get_mut(i)) { *v = 1.0; } + if let Some(q) = pure.get_mut(i) { + *q = Some(near.1); + } + // (An edge pixel can come within `PURE` of a class it holds none of: a black-to-white + // edge pixel as light as a gray class.) + if !in_flat_area(pix, width, height, i % width, i / width) + && !mixed.entry(key).or_insert_with(|| best_mixes(p, near, &subs)).tied.is_empty() + { + tied.push((i, key)); + } continue; } - let (res, weights) = mixed.entry(px.map(f32::to_bits)).or_insert_with(|| best_mix(p, near, &subs)); + let mixes = mixed.entry(key).or_insert_with(|| best_mixes(p, near, &subs)); if let Some(r) = residual.get_mut(i) { - *r = *res; + *r = mixes.residual; } - for &(c, wv) in weights.iter() { - if let Some(v) = planes.get_mut(c).and_then(|pl| pl.get_mut(i)) { - *v = wv; + set_weights(&mut planes, i, &mixes.best, 1.0); + // (A pixel in a flat area has no neighbor far enough from it for `across_edge`.) + if !mixes.tied.is_empty() && !in_flat_area(pix, width, height, i % width, i / width) { + tied.push((i, key)); + } + } + let mut switched = Vec::new(); + if tied.is_empty() { + return (planes, residual, switched); + } + // A pixel two mixes reproduce takes another mix of two classes whose farthest class lies nearer + // it than the best mix's does, in the flat areas around it ([`flat_classes`], [`RowDistances::around`]), + // when the pixel lies on an edge between that mix's classes ([`across_edge`]). For the best mix, + // the classes held by flat areas between two classes count as well ([`between_classes`]). A + // pixel within `PURE` of a class that is joined to at least [`LINE`] such pixels of that class + // (itself included) keeps it ([`pure_runs`]), as a one-pixel line of gray between black and white. + let flat = flat_classes(pix, &stands_for, width, height); + let between = between_classes(pix, &flat, &mixed, width, height); + let runs = pure_runs(&pure, width, height); + let mut flat_rows = RowDistances::new(|j| [flat.get(j).copied().flatten(), None], width, height, k); + let mut between_rows = (!between.is_empty()).then(|| RowDistances::new(|j| between.get(j).copied().unwrap_or([None; 2]), width, height, k)); + let mut dist = vec![NEARBY + 1; k]; + let mut kept = vec![NEARBY + 1; k]; + for (i, key) in tied { + if pure.get(i).copied().flatten().is_some() && runs.get(i).is_some_and(|&r| r >= LINE) { + continue; + } + let Some(mixes) = mixed.get(&key) else { continue }; + let edges: Vec<&Weights> = mixes.tied.iter().filter(|m| across_edge(pix, width, height, i, classes, &mixes.best, m)).collect(); + if edges.is_empty() { + continue; + } + flat_rows.around(i, &mut dist); + kept.copy_from_slice(&dist); + if let Some(rows) = &mut between_rows { + rows.around(i, &mut kept); + for (h, &d) in kept.iter_mut().zip(&dist) { + *h = (*h).min(d); + } + } + let now = farthest(&mixes.best, &kept); + let other = edges.into_iter().filter(|m| farthest(m, &dist) < now).min_by_key(|m| farthest(m, &dist)); + if let Some(other) = other { + switched.push((i, planes.iter().map(|pl| pl.get(i).copied().unwrap_or(0.0)).collect())); + // (`residual` keeps the best mix's error; the mix taken comes within `REPRODUCES` of the + // pixel as well.) + set_weights(&mut planes, i, &mixes.best, 0.0); + set_weights(&mut planes, i, other, 1.0); + } + } + (planes, residual, switched) +} + +/// Whether pixel `i` lies on an edge between the two classes of `other`, rather than in an area of +/// a class of `best` that `other` leaves out: for each such class, the pixel has a neighbor beyond +/// it toward each class of `other`, along the line between them. (A black-to-white edge pixel as +/// light as a gray has a darker neighbor and a lighter one; a gray stroke on white has no neighbor +/// darker than the gray.) +fn across_edge(pix: &[Px], width: usize, height: usize, i: usize, classes: &[[f64; 4]], best: &Weights, other: &Weights) -> bool { + let [(a, _), (b, _)] = other.as_slice() else { return false }; + let (Some(ca), Some(cb)) = (classes.get(*a), classes.get(*b)) else { return false }; + let ab: [f64; 4] = std::array::from_fn(|j| cb[j] - ca[j]); + let len2: f64 = ab.iter().map(|v| v * v).sum(); + if len2 <= 1e-12 { + return false; + } + let len = len2.sqrt(); + // Where `v` lies along the line from `a` to `b` (0 at `a`, 1 at `b`), and how far off it. + let place = |v: [f64; 4]| { + let t = (0..4).map(|j| (v[j] - ca[j]) * ab[j]).sum::() / len2; + let off = (0..4).map(|j| (v[j] - ca[j] - t * ab[j]).powi(2)).sum::().sqrt(); + (t, off) + }; + let (x, y) = (i % width.max(1), i / width.max(1)); + let neighbors: Vec = (y.saturating_sub(1)..(y + 2).min(height)) + .flat_map(|yy| (x.saturating_sub(1)..(x + 2).min(width)).map(move |xx| yy * width + xx)) + .filter(|&j| j != i) + .filter_map(|j| pix.get(j)) + .map(|q| place([f64::from(q[0]), f64::from(q[1]), f64::from(q[2]), f64::from(q[3])])) + .filter(|&(_, off)| off <= 2.0 * PURE) + .map(|(t, _)| t) + .collect(); + let left_out: Vec = best.iter().filter(|&&(c, w)| w > 0.0 && c != *a && c != *b).map(|&(c, _)| c).collect(); + !left_out.is_empty() + && left_out.iter().all(|&c| { + let Some(&cv) = classes.get(c) else { return false }; + let (tc, _) = place(cv); + neighbors.iter().any(|&t| (tc - t) * len > PURE) && neighbors.iter().any(|&t| (t - tc) * len > PURE) + }) +} + +/// How far the farthest class of `mix` lies, by the distances `dist` ([`RowDistances::around`]). +fn farthest(mix: &Weights, dist: &[usize]) -> usize { + mix.iter().filter(|&&(_, w)| w > 0.0).map(|&(c, _)| dist.get(c).copied().unwrap_or(NEARBY + 1)).max().unwrap_or(0) +} + +/// Sets pixel `i` of each class's plane in `weights` to the class's weight times `scale`. +fn set_weights(planes: &mut [Vec], i: usize, weights: &[(usize, f32)], scale: f32) { + for &(c, wv) in weights { + if let Some(v) = planes.get_mut(c).and_then(|pl| pl.get_mut(i)) { + *v = wv * scale; + } + } +} + +/// A class's weight in the mix of a flat area that stands for no class, from which the area +/// counts as holding that class. +const HOLDS: f32 = 0.25; + +/// The class each pixel stands for (`stands_for`) where the pixel lies in a flat area +/// ([`in_flat_area`]), and `None` elsewhere. +fn flat_classes(pix: &[Px], stands_for: &[Option], width: usize, height: usize) -> Vec> { + (0..width.saturating_mul(height)) + .map(|i| stands_for.get(i).copied().flatten().filter(|_| in_flat_area(pix, width, height, i % width, i / width))) + .collect() +} + +/// The classes held by flat areas that stand for no class (a flat color between two classes, as +/// with colors named some levels off): the classes of at least [`HOLDS`] weight in the area's mix +/// (at most two, the heaviest), held also by the pixels of exactly its color joined to it, flat or +/// not (its rows beside another color, where a soft edge or a shape close by leaves no flat pixel). +/// Empty when there is no such area. +fn between_classes(pix: &[Px], flat: &[Option], mixed: &HashMap<[u32; 4], Mixes>, width: usize, height: usize) -> Vec<[Option; 2]> { + let mut held = Vec::new(); + // Pixels holding classes, from which the pixels of their color are reached. + let mut reach: Vec = Vec::new(); + for (i, f) in flat.iter().enumerate() { + if f.is_some() || !in_flat_area(pix, width, height, i % width, i / width) { + continue; + } + let Some(mixes) = pix.get(i).and_then(|px| mixed.get(&px.map(f32::to_bits))) else { continue }; + let mut heavy: Vec<(usize, f32)> = mixes.best.iter().copied().filter(|&(_, w)| w >= HOLDS).collect(); + heavy.sort_by(|a, b| b.1.total_cmp(&a.1)); + if heavy.is_empty() { + continue; + } + if held.is_empty() { + held = vec![[None; 2]; width.saturating_mul(height)]; + } + if let Some(slot) = held.get_mut(i) { + *slot = [heavy.first().map(|&(c, _)| c), heavy.get(1).map(|&(c, _)| c)]; + reach.push(i); + } + } + while let Some(i) = reach.pop() { + let (Some(&classes), Some(key)) = (held.get(i), pix.get(i).map(|px| px.map(f32::to_bits))) else { continue }; + let (x, y) = (i % width, i / width); + for yy in y.saturating_sub(1)..(y + 2).min(height) { + for xx in x.saturating_sub(1)..(x + 2).min(width) { + let j = yy * width + xx; + if let (Some(slot @ [None, None]), Some(q)) = (held.get_mut(j), pix.get(j)) + && q.map(f32::to_bits) == key + { + *slot = classes; + reach.push(j); + } } } } - (planes, residual) + held } -/// A pixel's mix: the squared error it leaves and each class's weight. -type Mix = (f32, Vec<(usize, f32)>); +/// For each of `k` classes, how far along each row the nearest pixel holding that class (`at`, the +/// classes pixel `j` holds) lies from each pixel, up to `NEARBY + 1` when none lies within +/// [`NEARBY`]. A row is worked out when it is first asked for, so a few pixels cost a few rows. +struct RowDistances { + at: F, + width: usize, + k: usize, + /// Per row, once worked out: the distance for pixel `x` and class `c` at `x * k + c`. + rows: Vec>>, +} + +impl [Option; 2]> RowDistances { + const NONE: u8 = (NEARBY + 1) as u8; + + fn new(at: F, width: usize, height: usize, k: usize) -> Self { + Self { at, width, k, rows: vec![None; height] } + } + + fn row(&mut self, y: usize) -> &[u8] { + if self.rows.get(y).is_some_and(Option::is_none) { + let built = self.build(y); + if let Some(slot) = self.rows.get_mut(y) { + *slot = Some(built); + } + } + self.rows.get(y).and_then(Option::as_deref).unwrap_or(&[]) + } + + fn build(&self, y: usize) -> Vec { + let (width, k) = (self.width, self.k); + let mut row = vec![Self::NONE; width.saturating_mul(k)]; + // Toward the right, then toward the left, each from the last pixel holding the class. + let mut last = vec![Self::NONE; k]; + for x in 0..width { + let held = (self.at)(y * width + x); + for (c, d) in last.iter_mut().enumerate() { + *d = if held.contains(&Some(c)) { 0 } else { d.saturating_add(1).min(Self::NONE) }; + if let Some(r) = row.get_mut(x * k + c) { + *r = *d; + } + } + } + last.fill(Self::NONE); + for x in (0..width).rev() { + for (c, d) in last.iter_mut().enumerate() { + let Some(r) = row.get_mut(x * k + c) else { continue }; + *d = if *r == 0 { 0 } else { d.saturating_add(1).min(Self::NONE) }; + *r = (*r).min(*d); + } + } + row + } + + /// For each class, how far (in pixels, the larger of the two axes) the nearest pixel holding + /// that class lies from pixel `i`, into `dist` (one entry per class); `NEARBY + 1` when none + /// lies within [`NEARBY`]. + fn around(&mut self, i: usize, dist: &mut [usize]) { + let (width, k, height) = (self.width.max(1), self.k, self.rows.len()); + let (x, y) = (i % width, i / width); + dist.fill(NEARBY + 1); + for yy in y.saturating_sub(NEARBY)..(y + NEARBY + 1).min(height) { + let dy = yy.abs_diff(y); + let row = self.row(yy); + for (c, d) in dist.iter_mut().enumerate().take(k) { + if let Some(&r) = row.get(x * k + c) { + *d = (*d).min(usize::from(r).max(dy)); + } + } + } + } +} + +/// For each pixel within [`PURE`] of a class (`pure`), how many pixels within `PURE` of the same +/// class are joined to it (itself included, through the eight neighbors); 0 for other pixels. +fn pure_runs(pure: &[Option], width: usize, height: usize) -> Vec { + let n = width.saturating_mul(height); + let mut size = vec![0usize; n]; + let mut seen = vec![false; n]; + let mut stack = Vec::new(); + let mut members = Vec::new(); + for start in 0..n { + let Some(c) = pure.get(start).copied().flatten() else { continue }; + if seen.get(start).copied().unwrap_or(true) { + continue; + } + if let Some(s) = seen.get_mut(start) { + *s = true; + } + stack.push(start); + members.clear(); + while let Some(i) = stack.pop() { + members.push(i); + let (x, y) = (i % width, i / width); + for yy in y.saturating_sub(1)..(y + 2).min(height) { + for xx in x.saturating_sub(1)..(x + 2).min(width) { + let j = yy * width + xx; + if pure.get(j).copied().flatten() == Some(c) + && let Some(s) = seen.get_mut(j) + && !*s + { + *s = true; + stack.push(j); + } + } + } + } + for &i in &members { + if let Some(s) = size.get_mut(i) { + *s = members.len(); + } + } + } + size +} + +/// Whether every neighbor of the pixel at (`x`, `y`) inside the image lies within [`FLAT`] of it. +fn in_flat_area(pix: &[Px], width: usize, height: usize, x: usize, y: usize) -> bool { + let Some(p) = pix.get(y * width + x) else { return false }; + (y.saturating_sub(1)..(y + 2).min(height)).all(|yy| { + (x.saturating_sub(1)..(x + 2).min(width)).all(|xx| { + pix.get(yy * width + xx).is_some_and(|q| (0..4).map(|j| (f64::from(p[j]) - f64::from(q[j])).powi(2)).sum::().sqrt() <= FLAT) + }) + }) +} + +/// Each class's weight in a pixel's mix. +type Weights = Vec<(usize, f32)>; + +/// A mix tried for a pixel: its squared error with its penalty, the penalty, and its classes with +/// their weights (the first `n`). +#[derive(Clone, Copy)] +struct Candidate { + res: f64, + penalty: f64, + idx: [usize; 3], + w: [f64; 3], + n: usize, +} + +impl Candidate { + fn weights(&self) -> Weights { + self.idx.iter().zip(self.w).take(self.n).map(|(&c, w)| (c, w as f32)).collect() + } +} + +/// How a pixel value is mixed: the squared error the best mix leaves, each class's weight in it, +/// and the other mixes of two classes that reproduce the value ([`REPRODUCES`]). +struct Mixes { + residual: f32, + best: Weights, + tied: Vec, +} /// The mix of at most three classes that reproduces `p` best, starting from the nearest single -/// class `near` (squared distance, class). -fn best_mix(p: [f64; 4], near: (f64, usize), subs: &[Subset]) -> Mix { +/// class `near` (squared distance, class), and the other mixes that also reproduce it. +fn best_mixes(p: [f64; 4], near: (f64, usize), subs: &[Subset]) -> Mixes { + let reproduces = |m: &Candidate| m.res - m.penalty <= REPRODUCES; // Best single class is the starting point; try every two- and three-class mix. - let mut best = (near.0, vec![(near.1, 1.0f64)]); - let mut best_penalty = 0.0; - for s in subs { + let mut top = Candidate { res: near.0, penalty: 0.0, idx: [near.1, 0, 0], w: [1.0, 0.0, 0.0], n: 1 }; + // The other mixes of two classes that reproduce the pixel, also when the best mix does not (a + // single class carries no penalty and can come nearer than an exact mix of two): only a mix of + // two is switched to ([`across_edge`]). A mix holding the nearest class when that class alone + // reproduces the pixel adds a class of next to no weight to it, and is left out. (At most one + // candidate per pair of classes, so the list stays small with many classes.) Each is kept with + // its subset's index, and the best mix found is left out of them at the end. + let single = reproduces(&top).then_some(near.1); + let mut top_at = None; + let mut pairs: Vec<(usize, Candidate)> = Vec::new(); + for (at, s) in subs.iter().enumerate() { let mut rhs = [0.0f64; 3]; for (j, r) in rhs.iter_mut().enumerate().take(s.n) { *r = (0..4).map(|c| s.cols[j][c] * p[c]).sum::() + LAMBDA * LAMBDA; @@ -180,12 +547,17 @@ fn best_mix(p: [f64; 4], near: (f64, usize), subs: &[Subset]) -> Mix { let m: f64 = (0..s.n).map(|j| w[j] * s.cols[j][c]).sum(); res += (pc - m).powi(2); } - if res < best.0 { - best = (res, (0..s.n).map(|j| (s.idx[j], w[j])).collect()); - best_penalty = s.penalty; + let m = Candidate { res, penalty: s.penalty, idx: s.idx, w, n: s.n }; + if m.n == 2 && reproduces(&m) && single.is_none_or(|c| m.idx[0] != c && m.idx[1] != c) { + pairs.push((at, m)); + } + if res < top.res { + top = m; + top_at = Some(at); } } - ((best.0 - best_penalty).max(0.0) as f32, best.1.into_iter().map(|(c, w)| (c, w as f32)).collect()) + let tied = pairs.iter().filter(|&&(at, _)| Some(at) != top_at).map(|(_, m)| m.weights()).collect(); + Mixes { residual: (top.res - top.penalty).max(0.0) as f32, best: top.weights(), tied } } #[cfg(test)] @@ -199,7 +571,7 @@ mod tests { #[test] fn pure_pixels_are_exact() { let pix = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0]]; - let (pl, _) = unmix(&pix, 2, 1, &classes()); + let (pl, _, _) = unmix(&pix, 2, 1, &classes()); assert_eq!((pl[0][0], pl[2][1]), (1.0, 1.0)); } @@ -207,7 +579,7 @@ mod tests { fn a_blend_of_two_colours_recovers_its_proportions() { // 30% red over 70% blue. let pix = vec![[0.3, 0.0, 0.7, 1.0]]; - let (pl, _) = unmix(&pix, 1, 1, &classes()); + let (pl, _, _) = unmix(&pix, 1, 1, &classes()); assert!((pl[0][0] - 0.3).abs() < 0.02, "red {}", pl[0][0]); assert!((pl[1][0] - 0.7).abs() < 0.02, "blue {}", pl[1][0]); } @@ -216,7 +588,7 @@ mod tests { fn a_half_transparent_edge_is_half_covered() { // Red at 40% coverage over nothing: premultiplied (0.4, 0, 0, 0.4). let pix = vec![[0.4, 0.0, 0.0, 0.4]]; - let (pl, _) = unmix(&pix, 1, 1, &classes()); + let (pl, _, _) = unmix(&pix, 1, 1, &classes()); assert!((pl[0][0] - 0.4).abs() < 0.02 && (pl[2][0] - 0.6).abs() < 0.02, "{:?}", (pl[0][0], pl[2][0])); } @@ -224,7 +596,7 @@ mod tests { fn a_colour_no_class_can_explain_leaves_a_large_residual() { // Green is none of red, blue or transparent, nor any blend of them. let pix = vec![[0.0, 0.8, 0.0, 1.0], [0.3, 0.0, 0.7, 1.0]]; - let (_, res) = unmix(&pix, 2, 1, &classes()); + let (_, res, _) = unmix(&pix, 2, 1, &classes()); assert!(res[0] > 0.1, "unexplained pixel residual {}", res[0]); assert!(res[1] < 1e-3, "an explained blend residual {}", res[1]); } @@ -235,8 +607,12 @@ mod tests { let off = [0.97, 0.02, 0.02, 1.0]; let mut pix = vec![off; 1000]; pix.extend([[0.3, 0.0, 0.7, 1.0], [1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0]]); - assert_eq!(count_ambiguous(&pix, &classes()), 2); - let (pl, res) = unmix(&pix, pix.len(), 1, &classes()); + // (The pure red and transparent pixels lie outside a flat area, so they count too: `unmix` + // checks whether another mix reproduces them.) + assert_eq!(count_ambiguous(&pix, pix.len(), 1, &classes()), 4); + // Pure pixels in a flat area do not count. + assert_eq!(count_ambiguous(&[[1.0, 0.0, 0.0, 1.0]; 9], 3, 3, &classes()), 0); + let (pl, res, _) = unmix(&pix, pix.len(), 1, &classes()); assert!(pl[0][0] > 0.95, "red {}", pl[0][0]); for i in 1..1000 { assert_eq!((pl[0][i], pl[1][i], pl[2][i], res[i]), (pl[0][0], pl[1][0], pl[2][0], res[0]), "pixel {i}"); @@ -244,10 +620,89 @@ mod tests { assert!((pl[0][1000] - 0.3).abs() < 0.02 && (pl[1][1000] - 0.7).abs() < 0.02); } + #[test] + fn an_edge_pixel_two_mixes_reproduce_takes_the_classes_beside_it() { + // A light gray lies on the line from black to white. A row of flat black, one edge pixel, + // flat white: the edge pixel is a blend of black and white, with no gray in it, also when + // it comes within `PURE` of the gray (0.905), and when the gray alone, which carries no + // penalty, comes nearer it than any mix of two does without reproducing it (0.91). + let classes = vec![[0.9, 0.9, 0.9, 1.0], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 0.0]]; + let row = |left: [f32; 4], v: f32| { + let mut pix = vec![left; 5]; + pix.push([v, v, v, 1.0]); + pix.extend([[1.0f32, 1.0, 1.0, 1.0]; 5]); + pix + }; + for v in [0.1f32, 0.3, 0.7, 0.85, 0.905, 0.91, 0.95, 0.98] { + let (pl, _, _) = unmix(&row([0.0, 0.0, 0.0, 1.0], v), 11, 1, &classes); + assert_eq!(pl[0][5], 0.0, "gray in a {v} edge pixel"); + assert!((pl[2][5] - v).abs() < 0.02 && (pl[1][5] - (1.0 - v)).abs() < 0.02, "{v}: black {} white {}", pl[1][5], pl[2][5]); + } + // Beside flat gray, a gray-to-white edge pixel keeps its gray. + let (pl, _, _) = unmix(&row([0.9, 0.9, 0.9, 1.0], 0.95), 11, 1, &classes); + assert!((pl[0][5] - 0.5).abs() < 0.02 && pl[1][5] == 0.0, "gray {} black {}", pl[0][5], pl[1][5]); + // A gray pixel between white ones, flat black four pixels off: a gray stroke, kept. + let mut pix = vec![[0.0f32, 0.0, 0.0, 1.0]; 4]; + pix.extend([[1.0f32, 1.0, 1.0, 1.0]; 2]); + pix.push([0.9, 0.9, 0.9, 1.0]); + pix.extend([[1.0f32, 1.0, 1.0, 1.0]; 5]); + let (pl, _, _) = unmix(&pix, 12, 1, &classes); + assert_eq!((pl[0][6], pl[1][6]), (1.0, 0.0), "the stroke"); + } + + #[test] + fn a_column_of_pixels_as_light_as_a_class_keeps_it() { + // Flat black, one column at the gray's value, flat white. In `LINE` rows or more the column + // is gray art (a one-pixel line, or a straight edge giving the same pixels); in fewer rows it + // is an edge between black and white. + let classes = vec![[0.9, 0.9, 0.9, 1.0], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 0.0]]; + for (rows, gray) in [(LINE, 1.0), (LINE + 10, 1.0), (LINE - 1, 0.0), (3, 0.0)] { + let mut pix = Vec::new(); + for _ in 0..rows { + pix.extend([[0.0f32, 0.0, 0.0, 1.0]; 5]); + pix.push([0.9, 0.9, 0.9, 1.0]); + pix.extend([[1.0f32, 1.0, 1.0, 1.0]; 5]); + } + let (pl, _, _) = unmix(&pix, 11, rows, &classes); + for y in 0..rows { + assert_eq!(pl[0][y * 11 + 5], gray, "{rows} rows: gray in row {y}"); + } + } + } + + #[test] + fn row_distances_match_a_scan_of_the_window_around_each_pixel() { + // Three classes scattered over 23 x 17 pixels, some pixels holding two, most none. + let (w, h, k) = (23usize, 17usize, 3usize); + let at = |j: usize| -> [Option; 2] { + match (j * 7919) % 97 { + 0..=2 => [Some(0), None], + 3 => [Some(1), Some(2)], + 4 | 5 => [None, Some(2)], + _ => [None; 2], + } + }; + let mut rows = RowDistances::new(at, w, h, k); + let mut dist = vec![0; k]; + for i in 0..w * h { + let (x, y) = (i % w, i / w); + let mut want = vec![NEARBY + 1; k]; + for yy in y.saturating_sub(NEARBY)..(y + NEARBY + 1).min(h) { + for xx in x.saturating_sub(NEARBY)..(x + NEARBY + 1).min(w) { + for c in at(yy * w + xx).into_iter().flatten() { + want[c] = want[c].min(xx.abs_diff(x).max(yy.abs_diff(y))); + } + } + } + rows.around(i, &mut dist); + assert_eq!(dist, want, "pixel ({x}, {y})"); + } + } + #[test] fn weights_sum_to_one() { let pix = vec![[0.2, 0.1, 0.5, 0.8], [0.9, 0.0, 0.05, 0.95]]; - let (pl, _) = unmix(&pix, 2, 1, &classes()); + let (pl, _, _) = unmix(&pix, 2, 1, &classes()); for i in 0..2 { let s: f32 = pl.iter().map(|p| p[i]).sum(); assert!((s - 1.0).abs() < 1e-4, "pixel {i} sums to {s}"); diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index 99b3c42845..27b7727b38 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -283,11 +283,11 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b /// channels within 8 levels of each other) is therefore also continuous tone when eight colors /// stay more than 0.065 as far as a single color does. So is a sepia, single-hue or duotone /// picture whose tones follow one curve through the color cube (19 in 20 of its opaque pixels on -/// it, see [`on_one_curve`]). Neither is when sixteen colors stay at most a fifth as far as eight -/// do. That exception holds for flat art of up to sixteen clean levels, gray or tinted, where -/// sixteen colors come within about a level of every pixel. On gray and tinted photos sixteen -/// colors stay 0.18 to 0.57 as far as eight do, and on most anti-aliased or noisy gray flat art -/// 0.24 to 0.5 as far, so the exception holds for neither. +/// it, see [`on_one_curve`]). Neither is, however far eight colors stay, when sixteen colors stay +/// at most a fifth as far as eight do. That exception holds for flat art of up to sixteen clean +/// levels, gray or tinted, where sixteen colors come within about a level of every pixel. On gray +/// and tinted photos sixteen colors stay 0.18 to 0.57 as far as eight do, and on most anti-aliased +/// or noisy gray flat art 0.24 to 0.5 as far, so the exception holds for neither. pub(crate) fn continuous_tone(img: &Raster) -> bool { let hk = HistKey::new(img); let mut bins = vec![Bin::default(); 1 << 15]; @@ -318,10 +318,14 @@ pub(crate) fn continuous_tone(img: &Raster) -> bool { return false; } let one = unsaid(1); - if eight > 0.1 * one { - return true; + if eight <= 0.065 * one { + return false; } - eight > 0.065 * one && unsaid(16) > 0.2 * eight && (20 * neutral >= 19 * opaque || 20 * on_one_curve(img) >= 19 * opaque) + let one_line = || 20 * neutral >= 19 * opaque || 20 * on_one_curve(img) >= 19 * opaque; + if eight > 0.1 * one { + return !(unsaid(16) <= 0.2 * eight && one_line()); + } + unsaid(16) > 0.2 * eight && one_line() } /// How far (levels) a pixel may lie from the tone curve of its picture and still count as on it, diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 3b4e84989a..2bf9e1e2b6 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -2206,6 +2206,59 @@ fn tinted_flat_art_stays_flat_art() { } } +/// `levels` clean gray bands 20 px wide, evenly spaced from black to white. +fn even_gray_bands(levels: u32) -> Raster { + Raster::from_fn(levels * 20, 60, |x, _| { + let v = (x / 20 * 255 / (levels - 1)) as u8; + [v, v, v, 255] + }) +} + +#[test] +fn flat_art_of_up_to_sixteen_clean_levels_stays_flat_art() { + // Equal bands: eight colors leave more than a tenth of what a single color leaves. + let tints: [(&str, &[[f64; 3]]); 4] = [("gray", &GRAY_RAMP), ("sepia", &SEPIA), ("blue", &BLUE), ("duotone", &DUOTONE)]; + let mut smoothed = vec![]; + for (name, stops) in tints { + for levels in [10, 12, 14, 16] { + let bands = tinted(&even_gray_bands(levels), stops, 0.0); + if quantize::continuous_tone(&bands) { + smoothed.push(format!("{levels} {name} bands")); + } + if quantize::continuous_tone(&beside_transparent_colors(&bands)) { + smoothed.push(format!("{levels} {name} bands beside transparent colors")); + } + } + } + assert!(smoothed.is_empty(), "continuous tone: {smoothed:?}"); +} + +#[test] +fn thin_lines_on_flat_art_of_up_to_sixteen_clean_levels_are_traced() { + for levels in [12, 16] { + for (name, stops) in [("gray", &GRAY_RAMP[..]), ("sepia", &SEPIA[..])] { + let bands = even_gray_bands(levels); + // A 1 px black line across the bands at y = 30, and a 1 px white line down the second + // band at x = 25. + let lined = Raster::from_fn(bands.width, bands.height, |x, y| { + if y == 30 { + [0, 0, 0, 255] + } else if x == 25 { + [255, 255, 255, 255] + } else { + let i = ((y * bands.width + x) * 4) as usize; + [bands.rgba[i], bands.rgba[i + 1], bands.rgba[i + 2], 255] + } + }); + let res = trace(&tinted(&lined, stops, 0.0), &sixteen_colors()); + assert_eq!(res.palette.len(), levels as usize, "{levels} {name} bands: {:?}", res.palette); + // The black band with the black line; the white band above and below the line; the + // white line above and below it; the second band in four; each other band in two. + assert_eq!(res.paths.len(), 1 + 2 + 2 + 4 + 2 * (levels as usize - 3), "{levels} {name} bands"); + } + } +} + #[test] fn pictures_without_tones_are_not_continuous_tone() { let photo = tinted(&photo_scene(6.0, true), &SEPIA, 2.0); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a947dde852..486c70de88 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -186,6 +186,11 @@ pub(crate) fn dragging(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(drag_id()).is_some()) } +/// Whether the canvas is holding a Zoom tool drag: a marquee, which Space moves, or an animated zoom. +pub(crate) fn dragging_zoom(ctx: &egui::Context) -> bool { + ctx.data(|d| matches!(d.get_temp::(drag_id()), Some(Drag::ZoomBox { .. } | Drag::ZoomScrub { .. }))) +} + /// The part of `outer` inside the scroll bars, and the part of that inside the rulers, which shows /// the document. Normal Screen Mode and Full Screen Mode with Menu Bar keep the scroll bars along /// the right and bottom edges; Full Screen and Presentation Mode have none. diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 4d121eb2a3..57dbef9ff8 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -373,7 +373,7 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) } } Drag::ZoomBox { start } => { - let start = zoom_marquee_start(ui, start, before, p, space); + let start = zoom_marquee_start(ui, start, before, p); ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomBox { start })); marquee(ui.painter(), egui::Rect::from_two_pos(start, p)); } @@ -480,7 +480,7 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po let ui_zoom = app.canvas_ui_zoom; match d { Drag::ZoomBox { start } => { - let start = zoom_marquee_start(ui, start, before, p, space); + let start = zoom_marquee_start(ui, start, before, p); ui.data_mut(|dd| dd.remove::(zoom_space_free_id())); let r = egui::Rect::from_two_pos(start, p); if let Some(vm) = app.view_mut() { @@ -612,22 +612,25 @@ fn zoom_space_free_id() -> egui::Id { /// sizes the marquee until it is let go. A marquee with no size yet is sized by the movement, so /// Space pressed before the pointer moves does not keep it at the press point. The frame's pointer /// moves and Space presses and releases are taken in the order they came, so each move follows the -/// Space state it came with. -fn zoom_marquee_start(ui: &Ui, start: Pos2, before: Option, p: Pos2, space: bool) -> Pos2 { +/// Space state it came with. The Space key counts as held whatever the modifiers, so a marquee that +/// Cmd+Space started while the Type tool edits text follows the same rules once Cmd is let go. +fn zoom_marquee_start(ui: &Ui, start: Pos2, before: Option, p: Pos2) -> Pos2 { enum Step { Space(bool), Move(Pos2), } let id = zoom_space_free_id(); - let steps: Vec = ui.input(|i| { - i.events + let (steps, space): (Vec, bool) = ui.input(|i| { + let steps = i + .events .iter() .filter_map(|e| match e { egui::Event::Key { key: egui::Key::Space, pressed, repeat: false, .. } => Some(Step::Space(*pressed)), egui::Event::PointerMoved(q) => Some(Step::Move(*q)), _ => None, }) - .collect() + .collect(); + (steps, i.key_down(egui::Key::Space)) }); // Space as it was when the frame began: the opposite of its first press or release, else as it is now. let mut held = steps.iter().find_map(|s| if let Step::Space(down) = s { Some(!down) } else { None }).unwrap_or(space); diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index 76fb599fda..0a26f26843 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -107,17 +107,16 @@ impl Panes { } let current = self.shown.get(&slot).is_some_and(|(k, _)| *k == key) || self.pending.get(&slot) == Some(&key); if !current { + // The document draws through its own assigned profiles, not the front document's, on + // the worker too. + let cms = vectorcraft_engine::cmd::colormgmt::doc_cms(&doc); let mut queued = false; if let Some(worker) = &self.worker { - let (job_doc, job_opts, job_display) = (doc.clone(), opts.clone(), display.clone()); + let (job_doc, job_opts, job_display, job_cms) = (doc.clone(), opts.clone(), display.clone(), cms.clone()); queued = worker.submit( slot, (slot, key.clone()), - Box::new(move |r| { - let mut img = r.render(&job_doc, w, h, view, &job_opts); - crate::display_profile::convert(job_display.as_deref(), &mut img.pixels); - Some(img) - }), + Box::new(move |r| Some(render_pane(r, (&job_doc, w, h, view, &job_opts), job_display.as_deref(), job_cms))), ); if queued { self.pending.insert(slot, key.clone()); @@ -126,8 +125,8 @@ impl Panes { } } if !queued { - let mut img = self.renderer.get_or_insert_with(vectorcraft_render::Renderer::new).render(&doc, w, h, view, &opts); - crate::display_profile::convert(display.as_deref(), &mut img.pixels); + let r = self.renderer.get_or_insert_with(vectorcraft_render::Renderer::new); + let img = render_pane(r, (&doc, w, h, view, &opts), display.as_deref(), cms); self.upload(ctx, slot, key, &img); } } @@ -146,6 +145,19 @@ impl Panes { } } +/// Render `doc` (`w` × `h` pixels through `view`, with `opts`) through the color settings `cms`, +/// then convert it into the display profile `display`. +fn render_pane( + r: &mut vectorcraft_render::Renderer, + (doc, w, h, view, opts): (&vectorcraft_doc::Document, u32, u32, Affine, &vectorcraft_render::RenderOptions), + display: Option<&vectorcraft_color::cms::display::DisplayTransform>, + cms: Arc, +) -> vectorcraft_render::Rendered { + let mut img = vectorcraft_color::cms::with_active(cms, || r.render(doc, w, h, view, opts)); + crate::display_profile::convert(display, &mut img.pixels); + img +} + /// The grid of `n` panes in `area`, filled row by row: as many columns as the square root of `n` /// rounded up, and as many rows as those columns need. The panes meet edge to edge and cover /// `area`; the spare cells of the last row stay empty. @@ -294,8 +306,16 @@ fn fit_pane(v: &mut View, doc: &vectorcraft_doc::Document, rect: egui::Rect, ui_ /// art, grid, artboards, guides and selected objects, and its rulers and the room of the canvas's /// scroll bars, so the document stays where it is when the pane becomes the canvas. True when a /// press in the pane asks for its window to become the current one. A floating window draws its -/// view with this too ([`crate::doc_windows`]). +/// view with this too ([`crate::doc_windows`]). The document's colors, its paper and transparency +/// grid included, draw through its own assigned profiles, not the front document's. pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) -> bool { + let Some(st) = app.session.documents().get(w.0) else { return false }; + let cms = vectorcraft_engine::cmd::colormgmt::doc_cms(&st.doc); + vectorcraft_color::cms::with_active(cms, || draw_pane(app, ui, w, cell)) +} + +/// [`pane`], drawn through the color settings in place. +fn draw_pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) -> bool { let t = Tokens::get(ui.ctx()); let ppp = ui.ctx().pixels_per_point(); let (full, rect) = view_rects(app, cell, ppp); diff --git a/crates/ui-egui/src/canvas/panes/tests.rs b/crates/ui-egui/src/canvas/panes/tests.rs index cb1394f612..005b930d01 100644 --- a/crates/ui-egui/src/canvas/panes/tests.rs +++ b/crates/ui-egui/src/canvas/panes/tests.rs @@ -358,3 +358,147 @@ fn a_resized_window_fits_the_tiled_documents_again_unless_one_was_zoomed() { } assert_eq!(app.views[1].zoom, zoomed.zoom, "the zoomed document keeps its zoom"); } + +/// The RGB of the middle pixel of the render that document `i`'s pane uploaded in `out`. +fn pane_middle(app: &VectorcraftApp, out: &egui::FullOutput, i: usize) -> [u8; 3] { + let uid = app.session.documents()[i].uid; + let id = app.canvas.panes.shown.iter().find(|(slot, _)| slot.0 == uid).map(|(_, (_, tex))| tex.id()).unwrap(); + let image = out.textures_delta.set.iter().find(|(t, _)| **t == id).and_then(|(_, d)| d.last()).map(|d| d.image.clone()).unwrap(); + let egui::ImageData::Color(img) = image; + let p = img.pixels[img.size[1] / 2 * img.size[0] + img.size[0] / 2]; + [p.r(), p.g(), p.b()] +} + +#[test] +fn other_panes_draw_through_their_documents_profiles() { + const CHILD: &str = "VECTORCRAFT_TEST_PANE_PROFILES"; + if std::env::var_os(CHILD).is_none() { + // Color settings are process-wide: assign profiles in a separate test process. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "canvas::panes::tests::other_panes_draw_through_their_documents_profiles", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "isolated pane profile test failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + return; + } + use vectorcraft_color::cms::{self, Cms, ColorSettings}; + let mut app = app_with(2); + app.session.prefs.monitor_profile_display = false; + // Each document is covered by one RGB rectangle; the second is assigned Display P3. + for i in 0..2 { + app.run("document.activate", json!({"index": i})).unwrap(); + app.run("shape.rectangle", json!({"x": -50, "y": -50, "width": 500, "height": 400})).unwrap(); + app.run("paint.setFill", json!({"color": "#339966"})).unwrap(); + } + app.run("edit.assignProfile", json!({"rgb": cms::DISPLAY_P3})).unwrap(); + // Its simulated paper shows in another color. + app.run("document.setup", json!({"gridColors": ["#cc6633", "#ffffff"], "simulatePaper": true})).unwrap(); + let in_p3 = |r: u8, g: u8, b: u8| { + let color = vectorcraft_color::Color::Rgb { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0 }; + let p3 = Cms::new(&ColorSettings { rgb: cms::DISPLAY_P3.into(), ..cms::working_settings() }).unwrap().display_rgb(&color); + (color, p3.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)) + }; + let (color, p3) = in_p3(0x33, 0x99, 0x66); + let srgb = [0x33, 0x99, 0x66]; + assert_ne!(p3, srgb, "Display P3 draws the color differently"); + let (_, paper_p3) = in_p3(0xcc, 0x66, 0x33); + let near = |a: [u8; 3], b: [u8; 3]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1); + // Whether `out` fills a shape with the RGB `rgb`, give or take one. + let fills = |out: &egui::FullOutput, rgb: [u8; 3]| { + fn any(s: &Shape, f: &dyn Fn(Color32) -> bool) -> bool { + match s { + Shape::Vec(v) => v.iter().any(|s| any(s, f)), + Shape::Path(p) => f(p.fill), + _ => false, + } + } + out.shapes.iter().any(|c| any(&c.shape, &|c: Color32| c.a() == 255 && near([c.r(), c.g(), c.b()], rgb))) + }; + let ctx = egui::Context::default(); + let raw = || egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), + max_texture_side: Some(8192), + ..Default::default() + }; + app.run("window.arrange.tile", json!({})).unwrap(); + // The first document in front: the second one's pane draws through Display P3, its paper too. + app.run("document.activate", json!({"index": 0})).unwrap(); + let mut out = ctx.run_ui(raw(), |ui| app.canvas_area(ui)); + let shown = pane_middle(&app, &out, 1); + assert!(fills(&out, paper_p3), "the back document's paper draws through its Display P3: {paper_p3:?}"); + assert!(!fills(&out, [0xcc, 0x66, 0x33]), "the back document's paper does not draw through the working space"); + out.textures_delta.clear(); + assert!(near(shown, p3), "the back document draws through its Display P3: {shown:?}, not {p3:?}"); + // The same in a floating window. + app.run("window.arrange.consolidateAllWindows", json!({})).unwrap(); + app.run("document.activate", json!({"index": 1})).unwrap(); + app.run("window.arrange.floatInWindow", json!({})).unwrap(); + app.run("document.activate", json!({"index": 0})).unwrap(); + let floating = |app: &mut VectorcraftApp| { + ctx.run_ui(raw(), |ui| { + app.canvas_area(ui); + crate::doc_windows::show(app, ui.ctx()); + }) + }; + // The window takes its place in the first frame and fades in over the next few. + for _ in 0..30 { + floating(&mut app).textures_delta.clear(); + } + app.canvas.panes.clear(); + let mut out = floating(&mut app); + let shown = pane_middle(&app, &out, 1); + assert!(fills(&out, paper_p3), "the floating document's paper draws through its Display P3: {paper_p3:?}"); + out.textures_delta.clear(); + assert!(near(shown, p3), "the floating document draws through its Display P3: {shown:?}, not {p3:?}"); + app.run("window.arrange.consolidateAllWindows", json!({})).unwrap(); + app.run("window.arrange.tile", json!({})).unwrap(); + app.canvas.panes.clear(); + // The second document in front: the first one's pane draws through the working sRGB. + app.run("document.activate", json!({"index": 1})).unwrap(); + let mut out = ctx.run_ui(raw(), |ui| app.canvas_area(ui)); + let shown = pane_middle(&app, &out, 0); + out.textures_delta.clear(); + assert!(near(shown, srgb), "the back document draws through the working space: {shown:?}, not {srgb:?}"); + // The same off the UI thread. + app.canvas.panes.clear(); + app.canvas.panes.worker = crate::thumb_worker::ThumbWorker::start(&ctx, "test-panes"); + app.canvas.panes.worker_started = true; + ctx.run_ui(raw(), |ui| app.canvas_area(ui)).textures_delta.clear(); + let done = app.canvas.panes.worker.as_ref().unwrap().wait().unwrap(); + assert_eq!(done.key.0.0, app.session.documents()[0].uid); + let img = done.img.unwrap(); + let at = ((img.height / 2 * img.width + img.width / 2) * 4) as usize; + let shown = [img.pixels[at], img.pixels[at + 1], img.pixels[at + 2]]; + assert!(near(shown, srgb), "the worker draws the back document through the working space: {shown:?}, not {srgb:?}"); + // A CMYK document behind the front one draws through its own assigned CMYK profile. + app.run("document.activate", json!({"index": 0})).unwrap(); + let steps = json!([ + {"command": "edit.assignProfile", "params": {"cmyk": cms::DEVICE_CMYK}}, + {"command": "file.documentColorMode", "params": {"mode": "cmyk"}}, + ]); + app.run("command.batch", json!({"commands": steps})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("paint.setFill", json!({"color": {"c": 60, "m": 10, "y": 80, "k": 0}})).unwrap(); + app.run("document.activate", json!({"index": 1})).unwrap(); + let ink = vectorcraft_color::Color::cmyk(0.6, 0.1, 0.8, 0.0); + let to_u8 = |c: [f32; 3]| c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let device = to_u8(Cms::new(&ColorSettings { cmyk: cms::DEVICE_CMYK.into(), ..cms::working_settings() }).unwrap().display_rgb(&ink)); + let front = to_u8(cms::active().display_rgb(&ink)); + let close = |a: [u8; 3], b: [u8; 3]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 3); + assert!(!close(device, front), "the two CMYK profiles draw the color differently: {device:?}, {front:?}"); + app.canvas.panes.clear(); + ctx.run_ui(raw(), |ui| app.canvas_area(ui)).textures_delta.clear(); + let done = app.canvas.panes.worker.as_ref().unwrap().wait().unwrap(); + assert_eq!(done.key.0.0, app.session.documents()[0].uid); + let img = done.img.unwrap(); + let at = ((img.height / 2 * img.width + img.width / 2) * 4) as usize; + let shown = [img.pixels[at], img.pixels[at + 1], img.pixels[at + 2]]; + assert!(close(shown, device), "the back CMYK document draws through its Device CMYK: {shown:?}, not {device:?} (front: {front:?})"); + assert!(near(cms::active().display_rgb(&color).map(|v| (v * 255.0).round() as u8), p3), "the front document's profile stays active"); +} diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 5ef3789905..d5dfe4e307 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -7416,6 +7416,73 @@ fn space_types_a_space_while_editing_text_and_does_not_pan() { assert_eq!(plain(&app, id), "a ", "the text is as typed"); } +/// While the Type tool edits text, a zoom marquee that Cmd+Space started keeps its Space rules once +/// Cmd is let go: Space held since the press sizes it, and Space pressed again moves it on every +/// frame it stays held, without typing a space. With no marquee, Space types a space again, and +/// during an animated zoom drag it types none. +#[test] +fn space_moves_a_text_edit_zoom_marquee_after_cmd_is_let_go() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + let ctx = egui::Context::default(); + let id = start_typing(&mut app, &ctx); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() - vec2(100.0, 50.0); + let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + let corners = |app: &mut VectorcraftApp| -> (Pos2, Pos2) { + let lines: Vec> = shapes(app, &ctx) + .into_iter() + .filter_map(|s| match s { + Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::from_white_alpha(230)) => Some(p.points), + _ => None, + }) + .collect(); + let [line] = &lines[..] else { panic!("one marquee: {lines:?}") }; + (line[0], line[2]) + }; + typing_frame(&mut app, &ctx, vec![Event::Text("a".into())]); + let z0 = app.view().unwrap().zoom; + // Space held since the Cmd+Space press sizes the marquee, also once Cmd is let go. + typing_frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::COMMAND), space(true, Modifiers::COMMAND), Event::PointerMoved(at)]); + typing_frame(&mut app, &ctx, vec![button(at, true, Modifiers::COMMAND)]); + typing_frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(50.0, 25.0))]); + typing_frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(at + vec2(80.0, 40.0))]); + typing_frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(100.0, 50.0))]); + assert_eq!(corners(&mut app), (at, at + vec2(100.0, 50.0)), "Space held since the press sizes it"); + // Let go and pressed again, Space moves the marquee on this frame and the frames after it. + typing_frame(&mut app, &ctx, vec![space(false, Modifiers::NONE)]); + typing_frame(&mut app, &ctx, vec![space(true, Modifiers::NONE), Event::Text(" ".into()), Event::PointerMoved(at + vec2(120.0, 60.0))]); + typing_frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(140.0, 70.0))]); + assert_eq!(corners(&mut app), (at + vec2(40.0, 20.0), at + vec2(140.0, 70.0)), "Space moves it and keeps its 100 × 50 size"); + assert_eq!(plain(&app, id), "a", "the Space that moves the marquee types nothing"); + typing_frame(&mut app, &ctx, vec![space(false, Modifiers::NONE)]); + typing_frame(&mut app, &ctx, vec![space(true, Modifiers::NONE), Event::Ime(egui::ImeEvent::Commit(" ".into()))]); + assert_eq!(plain(&app, id), "a", "nor does a space an input method commits for it"); + // Let go, the drag sizes it again, and the release zooms to the moved marquee. + typing_frame(&mut app, &ctx, vec![space(false, Modifiers::NONE), Event::PointerMoved(at + vec2(180.0, 95.0))]); + assert_eq!(corners(&mut app), (at + vec2(40.0, 20.0), at + vec2(180.0, 95.0)), "let go, the drag sizes it again"); + typing_frame(&mut app, &ctx, vec![button(at + vec2(180.0, 95.0), false, Modifiers::NONE)]); + let want = z0 * f64::from((rect.width() / 140.0).min(rect.height() / 75.0)); + assert!((app.view().unwrap().zoom - want).abs() < 1e-6, "{z0} → {}, not {want}", app.view().unwrap().zoom); + assert!(app.session.tool_wants_text(), "editing goes on"); + // With no marquee, Space types a space. + typing_frame(&mut app, &ctx, vec![space(true, Modifiers::NONE), Event::Text(" ".into())]); + typing_frame(&mut app, &ctx, vec![space(false, Modifiers::NONE)]); + assert_eq!(plain(&app, id), "a ", "Space types a space"); + // With Animated Zoom, Space during the Cmd+Space drag does not type a space either. + app.run("prefs.set", json!({"key": "animatedZoom", "value": true})).unwrap(); + typing_frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::COMMAND), space(true, Modifiers::COMMAND), Event::PointerMoved(at)]); + typing_frame(&mut app, &ctx, vec![button(at, true, Modifiers::COMMAND)]); + typing_frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::NONE), space(false, Modifiers::NONE)]); + typing_frame(&mut app, &ctx, vec![space(true, Modifiers::NONE), Event::Text(" ".into())]); + typing_frame(&mut app, &ctx, vec![button(at, false, Modifiers::NONE)]); + typing_frame(&mut app, &ctx, vec![space(false, Modifiers::NONE)]); + assert_eq!(plain(&app, id), "a ", "the text is as typed"); +} + #[test] fn space_shift_drag_rotates_the_view_whatever_the_tool() { use egui::{Event, Modifiers}; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2779bc7f6d..26f67a0365 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1274,7 +1274,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques /// Hides in `reply` the libraries hidden now and in `before`, and the paths outside the read root that /// `sent`, the request's params, holds ([`handle`]), and the recovery folders outside the read root -/// that are in use or that a failure message named. The `path` and `files` entries of the reply's +/// that are in use or that a failure message named, with each folder on the way to one that the reply +/// writes as a whole path ([`writes_whole_path`]): a failure names a file or a missing folder there. +/// The `path` and `files` entries of the reply's /// `result`, at any depth, that name a file the request gave inside the write roots, the file it /// wrote, stay as given ([`written_paths`]). fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { @@ -1288,16 +1290,42 @@ fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: hiding.merge(before); let mut paths = Vec::new(); sent_paths(sent, &mut paths); - // The status text of a recovery copy that failed names the recovery folder it went to. + // The status text of a recovery copy that failed names the recovery folder it went to, or the + // first folder on the way to it that is a file or is missing. let current = vectorcraft_engine::cmd::recovery::folder(&app.session).map(|(f, _)| f); for folder in app.recovery.named.iter().chain(current.as_ref()) { collect_sent_paths(&json!(folder), true, &mut paths); + for on_the_way in std::path::Path::new(folder).ancestors().skip(1).map(|p| p.to_string_lossy()) { + if !on_the_way.is_empty() && writes_whole_path(reply, &on_the_way) { + collect_sent_paths(&json!(on_the_way), true, &mut paths); + } + } } hiding.set_hidden(paths, cfg!(windows)); hide_library_ids(reply, &hiding); restore_written_paths(reply, kept); } +/// Does `v` write `path` in a string or a key at any depth with no path text before it (a letter, a +/// digit, `_`, `-`, `.`, `~` or a separator) or after it ([`continues_path`]), a Windows path +/// ([`splits_at_backslash`]) with either separator and in any case of ASCII letters too? Only the text +/// is compared: nothing on disk is looked up. +fn writes_whole_path(v: &Value, path: &str) -> bool { + let windows = splits_at_backslash(path); + let path_before = + |before: &str| before.chars().next_back().is_some_and(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')); + let whole = |s: &str| { + let spellings = if windows { vec![path.replace('\\', "/"), path.replace('/', "\\")] } else { vec![path.to_string()] }; + spellings.iter().any(|p| occurrences(s, p, windows).into_iter().any(|at| !path_before(&s[..at]) && !continues_path(&s[at + p.len()..]))) + }; + match v { + Value::String(s) => whole(s), + Value::Array(items) => items.iter().any(|item| writes_whole_path(item, path)), + Value::Object(fields) => fields.iter().any(|(key, item)| whole(key) || writes_whole_path(item, path)), + _ => false, + } +} + /// The strings that `v`'s path params hold at any depth ([`vectorcraft_engine::file_access::is_path_key`]) /// that name a file inside the write roots; `v` is a path param's value when `path_param` is set. fn written_paths(v: &Value, path_param: bool, out: &mut Vec) { diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index b60b1adfdc..d914c3624d 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -474,6 +474,36 @@ fn a_recovery_folder_outside_the_read_root_is_hidden_in_the_status() { assert_eq!(inspect(&mut app, format!("Copies go to {inside}")), json!(format!("Copies go to {inside}"))); } +/// `ui.inspect` shows a file on the way to a recovery folder outside the read root, which a failed +/// recovery copy names, by its name, and a missing folder on the way too. A path that only begins or +/// only ends with a folder on the way stays. +#[test] +fn a_file_on_the_way_to_a_recovery_folder_outside_the_read_root_is_hidden_in_the_status() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let outside = crate::tests_folder::Folder::new("recovery-in-the-way"); + let file = outside.file("private", b"a file"); + let folder = std::path::Path::new(&file).join("recovery").to_string_lossy().into_owned(); + app.session.recovery.set_default_folder(Some(folder)); + let root = crate::tests_folder::Folder::new("recovery-in-the-way-root"); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap(); + let inspect = |app: &mut VectorcraftApp, status: String| { + app.ui.status = status; + let reply = vectorcraft_engine::file_access::confine(roots.as_ref(), || input_request(app, "ui.inspect", json!({}))); + reply["result"]["ui"]["status"].clone() + }; + let status = format!("Couldn't save recovery data: {file} is not a folder"); + assert_eq!(inspect(&mut app, status), "Couldn't save recovery data: private is not a folder"); + let missing = outside.join("gone"); + let folder = missing.join("recovery").to_string_lossy().into_owned(); + app.session.recovery.set_default_folder(Some(folder)); + let status = format!("Couldn't save recovery data: couldn't make {}", missing.display()); + assert_eq!(inspect(&mut app, status), "Couldn't save recovery data: couldn't make gone"); + let under = format!("Copies go near {}", outside.join("other.svg").display()); + assert_eq!(inspect(&mut app, under.clone()), json!(under)); + let longer = format!("Copies go near /work{}", missing.display()); + assert_eq!(inspect(&mut app, longer.clone()), json!(longer)); +} + /// A path outside the read root that a request sent is shown by its file name in a reply that succeeded /// as in one that failed. #[test] diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index 16d22d4430..32d8dcb2e9 100644 --- a/crates/ui-egui/src/dialogs/graph_data.rs +++ b/crates/ui-egui/src/dialogs/graph_data.rs @@ -32,6 +32,8 @@ const MAX_COLS: usize = MAX_GRAPH_SERIES + 1; const MIN_ROWS: usize = 8; const MIN_COLS: usize = 6; const MAX_CELL: usize = 256; +/// The largest file Import Data reads, 1 MB (1,048,576 bytes), the same as Import Data Sets'. +const MAX_IMPORT_BYTES: usize = 1 << 20; /// The Cell Style limits (Adjust column width and decimal digits). const MAX_DECIMALS: u64 = 10; const MAX_WIDTH: u64 = 20; @@ -332,9 +334,16 @@ fn revert(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Text", &["txt", "tsv", "csv"])], ..Default::default() }; let path = crate::picks::open(app, &pick).ok_or("cancelled")?; - // The button is off where files can't be read. - let Some(read) = app.services.read.as_ref() else { return Ok(()) }; - let bytes = read(&path)?; + // One byte past the limit tells a larger file apart without reading all of it. + let bytes = match (&app.services.read_head, &app.services.read) { + (Some(read_head), _) => read_head(&path, MAX_IMPORT_BYTES + 1)?, + (None, Some(read)) => read(&path)?, + // The button is off where files can't be read. + (None, None) => return Ok(()), + }; + if bytes.len() > MAX_IMPORT_BYTES { + return Err("the file is larger than 1 MB".into()); + } replace_grid(d, grid_from_text(&decode(&bytes))); Ok(()) } @@ -527,6 +536,46 @@ mod tests { assert_eq!(p["series"].as_array().unwrap().len(), MAX_COLS - 1); } + /// Import Data reads a file no further than one byte past 1 MB where the host can read part of + /// a file: a larger file is refused with the sheet left as it was, and a file at the limit is + /// read. Where the host only reads whole files, a larger file is refused too. + #[test] + fn import_data_refuses_a_file_larger_than_its_limit() { + use std::{cell::RefCell, collections::HashMap, rc::Rc}; + let (mut app, _) = app_with(",a\nQ1,1", "column"); + open(&mut app).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + let at_limit = [b"\tb\nQ2\t2\n".as_slice(), &vec![b' '; MAX_IMPORT_BYTES - 8]].concat(); + let files: HashMap> = + [("/big.txt".to_string(), vec![b'1'; MAX_IMPORT_BYTES + 1]), ("/limit.txt".to_string(), at_limit)].into(); + let files = Rc::new(files); + let picked = Rc::new(RefCell::new(String::new())); + let p = picked.clone(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(p.borrow().clone()))); + app.services.read = Some(Box::new(|_: &str| Err("the whole file was read".to_string()))); + let asked = Rc::new(RefCell::new(vec![])); + let (f, a) = (files.clone(), asked.clone()); + app.services.read_head = Some(Box::new(move |path: &str, max: usize| { + a.borrow_mut().push(max); + Ok(f.get(path).ok_or("no such file")?.iter().copied().take(max).collect()) + })); + let before = grid(&d); + *picked.borrow_mut() = "/big.txt".into(); + assert_eq!(import(&mut app, &mut d), Err("the file is larger than 1 MB".to_string())); + assert_eq!(grid(&d), before, "the sheet is left as it was"); + *picked.borrow_mut() = "/limit.txt".into(); + import(&mut app, &mut d).unwrap(); + assert_eq!((cell_at(&d, 0, 1), cell_at(&d, 1, 0)), ("b", "Q2")); + assert_eq!(*asked.borrow(), [MAX_IMPORT_BYTES + 1; 2], "each read stops one byte past the limit"); + // A host that reads only whole files. + app.services.read_head = None; + let f = files.clone(); + app.services.read = Some(Box::new(move |path: &str| Ok(f.get(path).ok_or("no such file")?.clone()))); + *picked.borrow_mut() = "/big.txt".into(); + assert_eq!(import(&mut app, &mut d), Err("the file is larger than 1 MB".to_string())); + assert_eq!(cell_at(&d, 0, 1), "b"); + } + #[test] fn transpose_switch_and_moves() { assert_eq!(transpose(&g(&[&["", "a", "b"], &["Q1", "1", "2"]])), g(&[&["", "Q1"], &["a", "1"], &["b", "2"]])); diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index 46a8dca322..28a6e57828 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -43,19 +43,27 @@ fn ids(rows: &[Value]) -> Vec { /// then about its modified ones, then about the fonts it opened without /// ([`super::missing_fonts`]). The web reads no linked files, so missing ones just show their /// previews there. The status bar shows what it says about the linked files followed by the open's -/// `notes` ([`crate::io::show_opened`]). +/// `notes` ([`crate::io::show_opened`]). On the web, with both missing and modified files and +/// Update Links not asking, the line about the missing files comes first and the line about the +/// modified ones is the first of the notes. pub fn after_open(app: &mut VectorcraftApp, r: &Value, notes: Vec) { after_open_for(app, r, notes, cfg!(target_arch = "wasm32")); } -fn after_open_for(app: &mut VectorcraftApp, r: &Value, notes: Vec, web: bool) { +fn after_open_for(app: &mut VectorcraftApp, r: &Value, mut notes: Vec, web: bool) { // The fonts' dialog waits for these questions (one dialog at a time). super::missing_fonts::after_open(app, r); let rows = |k: &str| r[k].as_array().cloned().unwrap_or_default(); let (missing, modified) = (rows("missingLinks"), ids(&rows("modifiedLinks"))); let notice = if missing.is_empty() || web { let unreadable = (!missing.is_empty()).then(|| unreadable_status(missing.len())); - ask_update(app, modified).or(unreadable) + match (ask_update(app, modified), unreadable) { + (Some(changed), Some(unreadable)) => { + notes.insert(0, changed); + Some(unreadable) + } + (changed, unreadable) => changed.or(unreadable), + } } else { open(app, missing, modified, ¬es); None @@ -390,6 +398,43 @@ mod tests { assert_eq!((app.ui.status.as_str(), &app.ui.status_notes), (notice, &None)); } + /// On the web, an open with linked files it can't read and modified ones, with Update Links + /// not asking: the status bar shows the line about the unreadable files and counts the line + /// about the modified ones as the first note, ahead of the open's notes, and keeps them all + /// for its hover. With Ask When Modified the update question opens and the line about the + /// unreadable files stays. + #[test] + fn a_web_open_with_unreadable_and_modified_links_keeps_both_lines() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.session.prefs.update_links = "manually".into(); + let missing = json!([{ "name": "a.png", "path": "/gone/a.png", "ids": [1] }, { "name": "b.png", "path": "/gone/b.png", "ids": [2] }]); + let r = json!({ "missingLinks": missing, "modifiedLinks": [{ "ids": [3] }] }); + let unreadable = "2 linked files can't be read here: their previews show"; + let changed = changed_on_disk_status(1); + + let notes = vec!["a note".to_string(), "another note".to_string()]; + after_open_for(&mut app, &r, notes.clone(), true); + let kept = [vec![changed.clone()], notes].concat(); + assert_eq!(app.ui.status, crate::io::opened_after_notice(unreadable, 3, &changed)); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), kept))); + assert!(app.ui.dialog.is_none()); + + after_open_for(&mut app, &r, vec![], true); + assert_eq!(app.ui.status, crate::io::opened_after_notice(unreadable, 1, &changed)); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), vec![changed.clone()]))); + + // One kind of line alone shows as before. + after_open_for(&mut app, &json!({ "modifiedLinks": [{ "ids": [3] }] }), vec![], true); + assert_eq!((app.ui.status.clone(), &app.ui.status_notes), (changed, &None)); + + app.session.prefs.update_links = "askWhenModified".into(); + after_open_for(&mut app, &r, vec![], true); + let d = app.ui.dialog.as_ref().expect("asks"); + assert_eq!((d.kind.as_str(), d.str("__command")), (super::super::confirm::KIND, "links.update".to_string())); + assert_eq!((app.ui.status.as_str(), &app.ui.status_notes), (unreadable, &None)); + } + /// The line about modified linked files that follows the Linked File Not Found dialog keeps the /// notes of that open, also when another message was shown meanwhile. An open without notes /// shows the line alone, even while the status bar shows an earlier open's notes, and a later diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 8cb531e411..28944173dd 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6865,6 +6865,12 @@ breakText Glyphs Глифи @msg open the practice sample of `{id}` first (see learn.openSample) първо отворете примерния файл за упражнение на `{id}` (вижте learn.openSample) @msg finish `{id}` first (see learn.finish) първо завършете `{id}` (вижте learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` е последният урок в своето умение + Reset progress Нулиране на напредъка + Mark Tutorial Step Отбелязване на стъпка от урока + Reset Learn Progress Нулиране на напредъка в обучението +@msg missing `step` (see learn.show) липсва `step` (вижте learn.show) +@msg `step` must be a step number from 0 to {last} `step` трябва да е номер на стъпка от 0 до {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` е завършен (вижте learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Обновяване на отложените ефекти diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 57790c64c6..01b90e5148 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6918,6 +6918,12 @@ shape Open Otvoren @msg open the practice sample of `{id}` first (see learn.openSample) prvo otvorite uzorak za vježbu tutorijala `{id}` (vidi learn.openSample) @msg finish `{id}` first (see learn.finish) prvo završite `{id}` (vidi learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` je posljednji tutorijal u svojoj vještini + Reset progress Vrati napredak na početno + Mark Tutorial Step Označi korak tutorijala + Reset Learn Progress Vrati napredak učenja na početno +@msg missing `step` (see learn.show) nedostaje `step` (vidi learn.show) +@msg `step` must be a step number from 0 to {last} `step` mora biti broj koraka od 0 do {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` je završen (vidi learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Osvježi odgođene efekte diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 882adefa39..4ddd1d5605 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -7453,6 +7453,12 @@ breakText Glyphs Glifs @msg open the practice sample of `{id}` first (see learn.openSample) obriu primer la mostra de pràctica de `{id}` (vegeu learn.openSample) @msg finish `{id}` first (see learn.finish) acabeu primer `{id}` (vegeu learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` és l’últim tutorial de la seva habilitat + Reset progress Restableix el progrés + Mark Tutorial Step Marca el pas del tutorial + Reset Learn Progress Restableix el progrés d’aprenentatge +@msg missing `step` (see learn.show) falta `step` (vegeu learn.show) +@msg `step` must be a step number from 0 to {last} `step` ha de ser un número de pas de 0 a {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` està acabat (vegeu learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Actualitza els efectes ajornats @@ -7482,3 +7488,119 @@ breakText Glyphs Glifs Grommets Ullets Grommet Inset Distància dels ullets a la vora Diameter Diàmetre +# Status and error messages of image export, color profiles and libraries, 3D effects, path operations and scripts. +@msg 3D effects require finite coordinates within 100 million points Els efectes 3D requereixen coordenades finites dins de 100 milions de punts +@msg 3D effects require one to ten lights Els efectes 3D requereixen d’un a deu llums +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Profunditat BMP {_1}: 1, 4, 8, 16, 24 o 32 bits per píxel +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer La codificació BMP ha fallat: {width} × {height} píxels no caben al format ni a la memòria intermèdia de píxels +@msg GIF encoding failed: the palette image is malformed La codificació GIF ha fallat: la imatge de paleta està mal formada +@msg GIF encoding failed: {e} La codificació GIF ha fallat: {e} +@msg JPEG colour profile: {e} Perfil de color JPEG: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size La codificació JPEG ha fallat: la memòria intermèdia de píxels no coincideix amb la mida de la imatge +@msg JPEG encoding failed: {e} La codificació JPEG ha fallat: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size La codificació PNG ha fallat: la memòria intermèdia de píxels no coincideix amb la mida de la imatge +@msg PNG encoding failed: the palette image is malformed La codificació PNG ha fallat: la imatge de paleta està mal formada +@msg PNG encoding failed: {e} La codificació PNG ha fallat: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size La codificació PNG ha fallat: {width} × {height} píxels no és una mida d’imatge vàlida +@msg PSD colour profile: {e} Perfil de color PSD: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size La codificació PSD ha fallat: la memòria intermèdia de píxels no coincideix amb la mida de la imatge +@msg Revolve profile contains invalid coordinates El perfil de Revolució conté coordenades no vàlides +@msg Revolve profile is too complex El perfil de Revolució és massa complex +@msg Revolve projection is not finite La projecció de Revolució no és finita +@msg Revolve requires finite profile coordinates within 100 million points Revolució requereix coordenades de perfil finites dins de 100 milions de punts +@msg Revolve surface is too complex; reduce Segments or simplify the profile La superfície de Revolució és massa complexa; redueix Segments o simplifica el perfil +@msg Revolve surface is too complex; simplify the profile La superfície de Revolució és massa complexa; simplifica el perfil +@msg Revolve visibility cannot split the projected boundary La visibilitat de Revolució no pot dividir el contorn projectat +@msg Revolve visibility creates too many fragments La visibilitat de Revolució crea massa fragments +@msg Revolve visibility creates too many vector anchors La visibilitat de Revolució crea massa punts d’ancoratge vectorials +@msg Revolve visibility exceeds its geometry work limit La visibilitat de Revolució supera el seu límit de treball geomètric +@msg Revolve visibility exceeds its spatial index limit La visibilitat de Revolució supera el seu límit d’índex espacial +@msg Revolve visibility has too many boundary crossings La visibilitat de Revolució té massa encreuaments de contorn +@msg Revolve visibility has too many input anchors La visibilitat de Revolució té massa punts d’ancoratge d’entrada +@msg Revolve visibility requires convex cap sectors La visibilitat de Revolució requereix sectors de tapa convexos +@msg Revolve visibility requires finite projected faces La visibilitat de Revolució requereix cares projectades finites +@msg SVG options: {e} Opcions SVG: {e} +@msg Save for Web settings: {e} Paràmetres de Desa per a web: {e} +@msg TIFF colour profile: {e} Perfil de color TIFF: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size La codificació TIFF ha fallat: la memòria intermèdia de píxels no coincideix amb la mida de la imatge +@msg TIFF encoding failed: {e} La codificació TIFF ha fallat: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Profunditat Targa {_1}: 16, 24 o 32 bits per píxel +@msg Targa encoding failed: the pixel buffer doesn't match the image size La codificació Targa ha fallat: la memòria intermèdia de píxels no coincideix amb la mida de la imatge +@msg The 3D projection is not finite La projecció 3D no és finita +@msg The 3D shadow is too complex; turn Shadows off or simplify the art L’ombra 3D és massa complexa; desactiva Ombres o simplifica la il·lustració +@msg The shape contains invalid coordinates La forma conté coordenades no vàlides +@msg Too many Revolve faces for visibility evaluation Massa cares de Revolució per avaluar-ne la visibilitat +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft llegeix com a màxim {ASE_MAX_ENTRIES} colors i grups d’un fitxer d’intercanvi de mostres, i aquesta biblioteca en té {entries} +@msg WebP encoding failed: {e} La codificació WebP ha fallat: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `index` must be a whole number `index` ha de ser un nombre enter +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` és smallThumb, mediumThumb, largeThumb, smallList o largeList +@msg a line can't run from a point to itself una línia no pot anar d’un punt a ell mateix +@msg a line needs at least two points una línia necessita almenys dos punts +@msg a number cannot be read no es pot llegir un número +@msg a swatch exchange block is too long un bloc d’intercanvi de mostres és massa llarg +@msg a swatch exchange name is too long un nom d’intercanvi de mostres és massa llarg +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels una imatge de {sw} × {sh} no es pot redimensionar a {width} × {height} píxels +@msg a {width} × {height} image is too large una imatge de {width} × {height} és massa gran +@msg a {width} × {height} image: {e} una imatge de {width} × {height}: {e} +@msg an image could not be written: {e} no s’ha pogut escriure una imatge: {e} +@msg an image that couldn't be read was left out s’ha omès una imatge que no s’ha pogut llegir +@msg can't open “{name}”: {e} no es pot obrir “{name}”: {e} +@msg cannot align zip entries to {align} bytes no es poden alinear les entrades del zip a {align} bytes +@msg cannot open the automation {kind} root `{_1}`: {e} no es pot obrir l’arrel {kind} de l’automatització `{_1}`: {e} +@msg cannot read profile: {0} no es pot llegir el perfil: {0} +@msg color book version {version} isn't supported (only 1) la versió {version} del llibre de colors no és compatible (només 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) els llibres de colors en l’espai de color {other} no són compatibles (només RGB, CMYK i Lab) +@msg file name too long to zip: {_1} nom de fitxer massa llarg per afegir-lo al zip: {_1} +@msg file name too long to zip: {name} nom de fitxer massa llarg per afegir-lo al zip: {name} +@msg freeform line {i}: {e} línia de forma lliure {i}: {e} +@msg freeform point {i}: {e} punt de forma lliure {i}: {e} +@msg give `{key}` or `{other}`, not both indiqueu `{key}` o `{other}`, no tots dos +@msg grayscale profiles can't be working spaces yet els perfils d’escala de grisos encara no poden ser espais de treball +@msg input contains NaN or infinite coordinates l’entrada conté coordenades NaN o infinites +@msg input path could not be closed no s’ha pogut tancar el traçat d’entrada +@msg invalid ICC profile: {0} perfil ICC no vàlid: {0} +@msg invalid scale {scale} escala no vàlida {scale} +@msg its localized names take more than {_1} KB els seus noms localitzats ocupen més de {_1} KB +@msg its objects are nested too deep ({e}) els seus objectes estan imbricats massa profundament ({e}) +@msg its tag table lists the `{_1}` tag more than once la seva taula d’etiquetes inclou l’etiqueta `{_1}` més d’una vegada +@msg not a graphic style library: {e} no és una biblioteca d’estils gràfics: {e} +@msg not a swatch library (.vcswatches or .gpl) no és una biblioteca de mostres (.vcswatches o .gpl) +@msg not a swatch library (format `{_1}`) no és una biblioteca de mostres (format `{_1}`) +@msg not a swatch library: {e} no és una biblioteca de mostres: {e} +@msg not an action set file ({e}) no és un fitxer de conjunt d’accions ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray l’espai de color del perfil, {other}, no és RGB, CMYK ni Gray +@msg regular expression too large expressió regular massa gran +@msg script file is not UTF-8 text: {value} el fitxer de l’script no és text UTF-8: {value} +@msg stop {i}: {e} la parada {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) la versió {major}.{minor} del fitxer d’intercanvi de mostres no és compatible (només 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors el llibre de colors conté més de {ASE_MAX_ENTRIES} colors +@msg the color book is cut short el llibre de colors està tallat +@msg the color book's names take more than {_1} MB els noms del llibre de colors ocupen més de {_1} MB +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution la imatge faria {w} × {h} píxels; les exportacions ràster estan limitades a {MAX_RASTER_SIDE} píxels per costat i {_1} megapíxels: reduïu l’escala o la resolució +@msg the optimised image can't be read back: {e} la imatge optimitzada no es pot tornar a llegir: {e} +@msg the pixel buffer doesn't match the image size la memòria intermèdia de píxels no coincideix amb la mida de la imatge +@msg the preview: {e} la previsualització: {e} +@msg the request may not read the New Document profiles folder la sol·licitud no pot llegir la carpeta de perfils de document nou +@msg the shapes are too degenerate to combine les formes són massa degenerades per combinar-les +@msg the shapes cross themselves and each other too often to combine les formes es creuen amb si mateixes i entre elles massa vegades per combinar-les +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups el fitxer d’intercanvi de mostres conté més de {ASE_MAX_ENTRIES} colors i grups +@msg the swatch exchange file is cut short el fitxer d’intercanvi de mostres està tallat +@msg the {_1} plate can't be read: {e} la planxa {_1} no es pot llegir: {e} +@msg this binary DXF file is damaged or cut short aquest fitxer DXF binari està malmès o tallat +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that aquest és un dibuix DWG, que no es pot llegir: desa’l des de l’aplicació CAD com a DXF i obre’l +@msg too many classes massa classes +@msg too many groups massa grups +@msg too many layers for PSD (at most 32767) massa capes per a PSD (com a màxim 32767) +@msg too many swatch exchange blocks massa blocs d’intercanvi de mostres +@msg unknown colour profile: {0} perfil de color desconegut: {0} +@msg {_1} clashes with a built-in profile name {_1} coincideix amb el nom d’un perfil integrat +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} píxels és massa gran per a GIF (com a màxim 65535 píxels per costat): reduïu la resolució +@msg {_1}: no usable transform to/from sRGB {_1}: no hi ha cap transformació utilitzable a sRGB o des de sRGB +@msg {name} is not a CMYK profile {name} no és un perfil CMYK +@msg {name} is not an RGB profile {name} no és un perfil RGB +@msg {path}: could not be read {path}: no s’ha pogut llegir +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} píxels és massa gran per a JPEG (com a màxim 65535 píxels per costat): reduïu la resolució +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} píxels és massa gran per a Targa (com a màxim 65535 píxels per costat): reduïu la resolució +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} píxels és massa gran per a PSD (com a màxim {MAX_SIDE} píxels per costat): reduïu la resolució diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d95879fc03..211982f086 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -7445,6 +7445,122 @@ breakText Glyphs Glyfy @plural {n} line cut|{n} lines cut {n} řádek zkrácen|{n} řádky zkráceny|{n} řádků zkráceno @msg `first` and `last` must be numbers of lines the console holds, first before last `first` a `last` musí být čísla řádků, které konzole uchovává, `first` před `last` +# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. +@msg 3D effects require finite coordinates within 100 million points 3D efekty vyžadují konečné souřadnice v rozsahu 100 milionů bodů +@msg 3D effects require one to ten lights 3D efekty vyžadují jedno až deset světel +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel hloubka BMP {_1}: 1, 4, 8, 16, 24 nebo 32 bitů na obrazový bod +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer kódování BMP selhalo: {width} × {height} obrazových bodů neodpovídá formátu ani vyrovnávací paměti obrazových bodů +@msg GIF encoding failed: the palette image is malformed kódování GIF selhalo: obraz s paletou je chybně utvořený +@msg GIF encoding failed: {e} kódování GIF selhalo: {e} +@msg JPEG colour profile: {e} barevný profil JPEG: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size kódování JPEG selhalo: vyrovnávací paměť obrazových bodů neodpovídá velikosti obrazu +@msg JPEG encoding failed: {e} kódování JPEG selhalo: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size kódování PNG selhalo: vyrovnávací paměť obrazových bodů neodpovídá velikosti obrazu +@msg PNG encoding failed: the palette image is malformed kódování PNG selhalo: obraz s paletou je chybně utvořený +@msg PNG encoding failed: {e} kódování PNG selhalo: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size kódování PNG selhalo: {width} × {height} obrazových bodů není platná velikost obrazu +@msg PSD colour profile: {e} barevný profil PSD: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size kódování PSD selhalo: vyrovnávací paměť obrazových bodů neodpovídá velikosti obrazu +@msg Revolve profile contains invalid coordinates Profil rotačního tělesa obsahuje neplatné souřadnice +@msg Revolve profile is too complex Profil rotačního tělesa je příliš složitý +@msg Revolve projection is not finite Projekce rotačního tělesa není konečná +@msg Revolve requires finite profile coordinates within 100 million points Rotační těleso vyžaduje konečné souřadnice profilu v rozsahu 100 milionů bodů +@msg Revolve surface is too complex; reduce Segments or simplify the profile Povrch rotačního tělesa je příliš složitý; snižte Segmenty nebo zjednodušte profil +@msg Revolve surface is too complex; simplify the profile Povrch rotačního tělesa je příliš složitý; zjednodušte profil +@msg Revolve visibility cannot split the projected boundary Výpočet viditelnosti rotačního tělesa nedokáže rozdělit promítnutou hranici +@msg Revolve visibility creates too many fragments Výpočet viditelnosti rotačního tělesa vytváří příliš mnoho fragmentů +@msg Revolve visibility creates too many vector anchors Výpočet viditelnosti rotačního tělesa vytváří příliš mnoho vektorových kotevních bodů +@msg Revolve visibility exceeds its geometry work limit Výpočet viditelnosti rotačního tělesa překračuje svůj limit geometrických výpočtů +@msg Revolve visibility exceeds its spatial index limit Výpočet viditelnosti rotačního tělesa překračuje svůj limit prostorového indexu +@msg Revolve visibility has too many boundary crossings Výpočet viditelnosti rotačního tělesa má příliš mnoho průsečíků hranic +@msg Revolve visibility has too many input anchors Výpočet viditelnosti rotačního tělesa má příliš mnoho vstupních kotevních bodů +@msg Revolve visibility requires convex cap sectors Výpočet viditelnosti rotačního tělesa vyžaduje konvexní výseče podstav +@msg Revolve visibility requires finite projected faces Výpočet viditelnosti rotačního tělesa vyžaduje konečné promítnuté stěny +@msg SVG options: {e} Možnosti SVG: {e} +@msg Save for Web settings: {e} Nastavení Uložit pro web: {e} +@msg TIFF colour profile: {e} barevný profil TIFF: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size kódování TIFF selhalo: vyrovnávací paměť obrazových bodů neodpovídá velikosti obrazu +@msg TIFF encoding failed: {e} kódování TIFF selhalo: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel hloubka Targa {_1}: 16, 24 nebo 32 bitů na obrazový bod +@msg Targa encoding failed: the pixel buffer doesn't match the image size kódování Targa selhalo: vyrovnávací paměť obrazových bodů neodpovídá velikosti obrazu +@msg The 3D projection is not finite 3D projekce není konečná +@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D stín je příliš složitý; vypněte Stíny nebo zjednodušte kresbu +@msg The shape contains invalid coordinates Tvar obsahuje neplatné souřadnice +@msg Too many Revolve faces for visibility evaluation Rotační těleso má příliš mnoho stěn pro výpočet viditelnosti +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft čte ze souboru pro výměnu vzorníku nejvýše {ASE_MAX_ENTRIES} barev a skupin a tato knihovna jich má {entries} +@msg WebP encoding failed: {e} kódování WebP selhalo: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` je smallThumb, mediumThumb, largeThumb, smallList nebo largeList +@msg a line can't run from a point to itself čára nemůže vést z bodu zpět do téhož bodu +@msg a line needs at least two points čára potřebuje alespoň dva body +@msg a number cannot be read číslo nelze přečíst +@msg a swatch exchange block is too long blok souboru pro výměnu vzorníku je příliš dlouhý +@msg a swatch exchange name is too long název v souboru pro výměnu vzorníku je příliš dlouhý +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels velikost obrazu {sw} × {sh} nelze změnit na {width} × {height} obrazových bodů +@msg a {width} × {height} image is too large obraz {width} × {height} je příliš velký +@msg a {width} × {height} image: {e} obraz {width} × {height}: {e} +@msg an image could not be written: {e} obraz se nepodařilo zapsat: {e} +@msg an image that couldn't be read was left out obraz, který nešlo přečíst, byl vynechán +@msg can't open “{name}”: {e} nelze otevřít „{name}“: {e} +@msg cannot align zip entries to {align} bytes položky zipu nelze zarovnat na {align} bajtů +@msg cannot open the automation {kind} root `{_1}`: {e} nelze otevřít kořen automatizace pro {kind} `{_1}`: {e} +@msg cannot read profile: {0} profil nelze přečíst: {0} +@msg color book version {version} isn't supported (only 1) verze barevné knihy {version} není podporována (pouze 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) barevné knihy v barevném prostoru {other} nejsou podporovány (pouze RGB, CMYK a Lab) +@msg file name too long to zip: {_1} název souboru je pro zip příliš dlouhý: {_1} +@msg file name too long to zip: {name} název souboru je pro zip příliš dlouhý: {name} +@msg freeform line {i}: {e} volná čára {i}: {e} +@msg freeform point {i}: {e} volný bod {i}: {e} +@msg give `{key}` or `{other}`, not both zadejte `{key}` nebo `{other}`, ne obojí +@msg grayscale profiles can't be working spaces yet profily ve stupních šedi zatím nemohou být pracovními prostory +@msg input contains NaN or infinite coordinates vstup obsahuje souřadnice NaN nebo nekonečné souřadnice +@msg input path could not be closed vstupní cestu se nepodařilo uzavřít +@msg invalid ICC profile: {0} neplatný profil ICC: {0} +@msg invalid scale {scale} neplatné měřítko {scale} +@msg its localized names take more than {_1} KB jeho lokalizované názvy zabírají více než {_1} KB +@msg its objects are nested too deep ({e}) jeho objekty jsou vnořené příliš hluboko ({e}) +@msg its tag table lists the `{_1}` tag more than once jeho tabulka značek uvádí značku `{_1}` více než jednou +@msg not a graphic style library: {e} nejde o knihovnu grafických stylů: {e} +@msg not a swatch library (.vcswatches or .gpl) nejde o knihovnu vzorníku (.vcswatches nebo .gpl) +@msg not a swatch library (format `{_1}`) nejde o knihovnu vzorníku (formát `{_1}`) +@msg not a swatch library: {e} nejde o knihovnu vzorníku: {e} +@msg not an action set file ({e}) nejde o soubor sady akcí ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray barevný prostor profilu {other} není RGB, CMYK ani Gray +@msg regular expression too large regulární výraz je příliš velký +@msg script file is not UTF-8 text: {value} soubor skriptu není text v kódování UTF-8: {value} +@msg stop {i}: {e} zarážka {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) verze souboru pro výměnu vzorníku {major}.{minor} není podporována (pouze 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors barevná kniha obsahuje více než {ASE_MAX_ENTRIES} barev +@msg the color book is cut short barevná kniha je useknutá +@msg the color book's names take more than {_1} MB názvy barevné knihy zabírají více než {_1} MB +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution obraz by měl {w} × {h} obrazových bodů; rastrové exporty jsou omezeny na {MAX_RASTER_SIDE} obrazových bodů na stranu a {_1} megapixelů: snižte měřítko nebo rozlišení +@msg the optimised image can't be read back: {e} optimalizovaný obraz nelze zpětně přečíst: {e} +@msg the pixel buffer doesn't match the image size vyrovnávací paměť obrazových bodů neodpovídá velikosti obrazu +@msg the preview: {e} náhled: {e} +@msg the request may not read the New Document profiles folder požadavek nesmí číst složku profilů nového dokumentu +@msg the shapes are too degenerate to combine tvary jsou příliš degenerované na sloučení +@msg the shapes cross themselves and each other too often to combine tvary protínají samy sebe i sebe navzájem příliš často na sloučení +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups soubor pro výměnu vzorníku obsahuje více než {ASE_MAX_ENTRIES} barev a skupin +@msg the swatch exchange file is cut short soubor pro výměnu vzorníku je useknutý +@msg the {_1} plate can't be read: {e} výtažek {_1} nelze přečíst: {e} +@msg this binary DXF file is damaged or cut short tento binární soubor DXF je poškozený nebo useknutý +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that toto je výkres DWG, který nelze přečíst: uložte jej v aplikaci CAD jako DXF a otevřete ten +@msg too many classes příliš mnoho tříd znaků +@msg too many groups příliš mnoho skupin +@msg too many layers for PSD (at most 32767) příliš mnoho vrstev pro PSD (nejvýše 32767) +@msg too many swatch exchange blocks příliš mnoho bloků souboru pro výměnu vzorníku +@msg unknown colour profile: {0} neznámý barevný profil: {0} +@msg {_1} clashes with a built-in profile name {_1} koliduje s názvem vestavěného profilu +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} obrazových bodů je pro GIF příliš mnoho (nejvýše 65535 obrazových bodů na stranu): snižte rozlišení +@msg {_1}: no usable transform to/from sRGB {_1}: žádná použitelná transformace do sRGB nebo ze sRGB +@msg {name} is not a CMYK profile {name} není profil CMYK +@msg {name} is not an RGB profile {name} není profil RGB +@msg {path}: could not be read {path}: nelze přečíst +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} obrazových bodů je pro JPEG příliš mnoho (nejvýše 65535 obrazových bodů na stranu): snižte rozlišení +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} obrazových bodů je pro Targa příliš mnoho (nejvýše 65535 obrazových bodů na stranu): snižte rozlišení +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} obrazových bodů je pro PSD příliš mnoho (nejvýše {MAX_SIDE} obrazových bodů na stranu): snižte rozlišení + # Learn panel Learn Výuka Explore tutorials Prozkoumat výukové kurzy @@ -7472,6 +7588,12 @@ breakText Glyphs Glyfy @msg open the practice sample of `{id}` first (see learn.openSample) nejprve otevřete cvičnou ukázku kurzu `{id}` (viz learn.openSample) @msg finish `{id}` first (see learn.finish) nejprve dokončete `{id}` (viz learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` je poslední výukový kurz své dovednosti + Reset progress Obnovit postup + Mark Tutorial Step Označit krok výukového kurzu + Reset Learn Progress Obnovit postup učení +@msg missing `step` (see learn.show) chybí `step` (viz learn.show) +@msg `step` must be a step number from 0 to {last} `step` musí být číslo kroku od 0 do {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` je dokončen (viz learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Obnovit odložené efekty diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f320896f62..b3de0ae89a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6880,6 +6880,12 @@ breakText Glyphs Glyffer @msg open the practice sample of `{id}` first (see learn.openSample) åbn først øvelseseksemplet til `{id}` (se learn.openSample) @msg finish `{id}` first (see learn.finish) afslut først `{id}` (se learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` er den sidste vejledning i sin færdighed + Reset progress Nulstil fremskridt + Mark Tutorial Step Markér trin i vejledning + Reset Learn Progress Nulstil læringsfremskridt +@msg missing `step` (see learn.show) `step` mangler (se learn.show) +@msg `step` must be a step number from 0 to {last} `step` skal være et trinnummer fra 0 til {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` er afsluttet (se learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Opdatér udskudte effekter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d98afe5b03..d0610144ef 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -7068,6 +7068,12 @@ breakText Glyphs Glyphen @msg open the practice sample of `{id}` first (see learn.openSample) öffnen Sie zuerst die Übungsdatei von `{id}` (siehe learn.openSample) @msg finish `{id}` first (see learn.finish) schließen Sie zuerst `{id}` ab (siehe learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` ist das letzte Lernprogramm seiner Fertigkeit + Reset progress Fortschritt zurücksetzen + Mark Tutorial Step Schritt des Lernprogramms markieren + Reset Learn Progress Lernfortschritt zurücksetzen +@msg missing `step` (see learn.show) `step` fehlt (siehe learn.show) +@msg `step` must be a step number from 0 to {last} `step` muss eine Schrittnummer von 0 bis {last} sein +@msg `{id}` is finished (see learn.resetProgress) `{id}` ist abgeschlossen (siehe learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Zurückgestellte Effekte aktualisieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8facc3b3dd..d03f8c6958 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -7425,6 +7425,12 @@ breakText Glyphs Γλύφοι @msg open the practice sample of `{id}` first (see learn.openSample) ανοίξτε πρώτα το δείγμα εξάσκησης του `{id}` (δείτε learn.openSample) @msg finish `{id}` first (see learn.finish) ολοκληρώστε πρώτα το `{id}` (δείτε learn.finish) @msg `{id}` is the last tutorial in its skill το `{id}` είναι ο τελευταίος οδηγός εκμάθησης της δεξιότητάς του + Reset progress Επαναφορά προόδου + Mark Tutorial Step Σήμανση βήματος οδηγού εκμάθησης + Reset Learn Progress Επαναφορά προόδου εκμάθησης +@msg missing `step` (see learn.show) λείπει το `step` (δείτε learn.show) +@msg `step` must be a step number from 0 to {last} το `step` πρέπει να είναι αριθμός βήματος από 0 έως {last} +@msg `{id}` is finished (see learn.resetProgress) το `{id}` έχει ολοκληρωθεί (δείτε learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Ανανέωση εφέ σε αναμονή @@ -7454,3 +7460,120 @@ breakText Glyphs Γλύφοι Grommets Τρουκς Grommet Inset Απόσταση τρουκς από την άκρη Diameter Διάμετρος + +# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books, DXF import and PDF export. +@msg 3D effects require finite coordinates within 100 million points Τα εφέ 3D απαιτούν πεπερασμένες συντεταγμένες εντός 100 εκατομμυρίων τυπογραφικών στιγμών +@msg 3D effects require one to ten lights Τα εφέ 3D απαιτούν από ένα έως δέκα φώτα +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Βάθος BMP {_1}: 1, 4, 8, 16, 24 ή 32 bit ανά εικονοστοιχείο +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Η κωδικοποίηση BMP απέτυχε: τα {width} × {height} εικονοστοιχεία δεν ταιριάζουν στη μορφή ή στην προσωρινή μνήμη εικονοστοιχείων +@msg GIF encoding failed: the palette image is malformed Η κωδικοποίηση GIF απέτυχε: η εικόνα παλέτας είναι κακοσχηματισμένη +@msg GIF encoding failed: {e} Η κωδικοποίηση GIF απέτυχε: {e} +@msg JPEG colour profile: {e} Προφίλ χρώματος JPEG: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size Η κωδικοποίηση JPEG απέτυχε: η προσωρινή μνήμη εικονοστοιχείων δεν αντιστοιχεί στο μέγεθος της εικόνας +@msg JPEG encoding failed: {e} Η κωδικοποίηση JPEG απέτυχε: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size Η κωδικοποίηση PNG απέτυχε: η προσωρινή μνήμη εικονοστοιχείων δεν αντιστοιχεί στο μέγεθος της εικόνας +@msg PNG encoding failed: the palette image is malformed Η κωδικοποίηση PNG απέτυχε: η εικόνα παλέτας είναι κακοσχηματισμένη +@msg PNG encoding failed: {e} Η κωδικοποίηση PNG απέτυχε: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Η κωδικοποίηση PNG απέτυχε: τα {width} × {height} εικονοστοιχεία δεν είναι έγκυρο μέγεθος εικόνας +@msg PSD colour profile: {e} Προφίλ χρώματος PSD: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size Η κωδικοποίηση PSD απέτυχε: η προσωρινή μνήμη εικονοστοιχείων δεν αντιστοιχεί στο μέγεθος της εικόνας +@msg Revolve profile contains invalid coordinates Το προφίλ της περιστροφής γύρω από άξονα περιέχει μη έγκυρες συντεταγμένες +@msg Revolve profile is too complex Το προφίλ της περιστροφής γύρω από άξονα είναι πολύ σύνθετο +@msg Revolve projection is not finite Η προβολή της περιστροφής γύρω από άξονα δεν είναι πεπερασμένη +@msg Revolve requires finite profile coordinates within 100 million points Η περιστροφή γύρω από άξονα απαιτεί πεπερασμένες συντεταγμένες προφίλ εντός 100 εκατομμυρίων τυπογραφικών στιγμών +@msg Revolve surface is too complex; reduce Segments or simplify the profile Η επιφάνεια περιστροφής είναι πολύ σύνθετη· μειώστε τα «Τμήματα» ή απλοποιήστε το προφίλ +@msg Revolve surface is too complex; simplify the profile Η επιφάνεια περιστροφής είναι πολύ σύνθετη· απλοποιήστε το προφίλ +@msg Revolve visibility cannot split the projected boundary Ο έλεγχος ορατότητας της περιστροφής δεν μπορεί να διαιρέσει το προβαλλόμενο όριο +@msg Revolve visibility creates too many fragments Ο έλεγχος ορατότητας της περιστροφής δημιουργεί πάρα πολλά κομμάτια +@msg Revolve visibility creates too many vector anchors Ο έλεγχος ορατότητας της περιστροφής δημιουργεί πάρα πολλά διανυσματικά σημεία αγκύρωσης +@msg Revolve visibility exceeds its geometry work limit Ο έλεγχος ορατότητας της περιστροφής υπερβαίνει το όριο γεωμετρικών υπολογισμών του +@msg Revolve visibility exceeds its spatial index limit Ο έλεγχος ορατότητας της περιστροφής υπερβαίνει το όριο του χωρικού ευρετηρίου του +@msg Revolve visibility has too many boundary crossings Ο έλεγχος ορατότητας της περιστροφής έχει πάρα πολλές διασταυρώσεις ορίων +@msg Revolve visibility has too many input anchors Ο έλεγχος ορατότητας της περιστροφής έχει πάρα πολλά σημεία αγκύρωσης εισόδου +@msg Revolve visibility requires convex cap sectors Ο έλεγχος ορατότητας της περιστροφής απαιτεί κυρτούς τομείς καλυμμάτων +@msg Revolve visibility requires finite projected faces Ο έλεγχος ορατότητας της περιστροφής απαιτεί πεπερασμένες προβαλλόμενες έδρες +@msg SVG options: {e} Επιλογές SVG: {e} +@msg Save for Web settings: {e} Ρυθμίσεις αποθήκευσης για ιστό: {e} +@msg TIFF colour profile: {e} Προφίλ χρώματος TIFF: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size Η κωδικοποίηση TIFF απέτυχε: η προσωρινή μνήμη εικονοστοιχείων δεν αντιστοιχεί στο μέγεθος της εικόνας +@msg TIFF encoding failed: {e} Η κωδικοποίηση TIFF απέτυχε: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Βάθος Targa {_1}: 16, 24 ή 32 bit ανά εικονοστοιχείο +@msg Targa encoding failed: the pixel buffer doesn't match the image size Η κωδικοποίηση Targa απέτυχε: η προσωρινή μνήμη εικονοστοιχείων δεν αντιστοιχεί στο μέγεθος της εικόνας +@msg The 3D projection is not finite Η προβολή 3D δεν είναι πεπερασμένη +@msg The 3D shadow is too complex; turn Shadows off or simplify the art Η σκιά 3D είναι πολύ σύνθετη· απενεργοποιήστε τις «Σκιές» ή απλοποιήστε το σχέδιο +@msg The shape contains invalid coordinates Το σχήμα περιέχει μη έγκυρες συντεταγμένες +@msg Too many Revolve faces for visibility evaluation Πάρα πολλές έδρες περιστροφής για τον έλεγχο ορατότητας +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} Το VectorCraft διαβάζει το πολύ {ASE_MAX_ENTRIES} χρώματα και ομάδες από ένα αρχείο ανταλλαγής δειγμάτων, και αυτή η βιβλιοθήκη έχει {entries} +@msg WebP encoding failed: {e} Η κωδικοποίηση WebP απέτυχε: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `index` must be a whole number Το `index` πρέπει να είναι ακέραιος αριθμός +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList Το `view` είναι smallThumb, mediumThumb, largeThumb, smallList ή largeList +@msg a line can't run from a point to itself μια γραμμή δεν μπορεί να ξεκινά και να καταλήγει στο ίδιο σημείο +@msg a line needs at least two points μια γραμμή χρειάζεται τουλάχιστον δύο σημεία +@msg a number cannot be read ένας αριθμός δεν μπορεί να διαβαστεί +@msg a swatch exchange block is too long ένα μπλοκ του αρχείου ανταλλαγής δειγμάτων είναι πολύ μεγάλο +@msg a swatch exchange name is too long ένα όνομα του αρχείου ανταλλαγής δειγμάτων είναι πολύ μεγάλο +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels μια εικόνα {sw} × {sh} δεν μπορεί να αλλάξει μέγεθος σε {width} × {height} εικονοστοιχεία +@msg a {width} × {height} image is too large μια εικόνα {width} × {height} είναι πολύ μεγάλη +@msg a {width} × {height} image: {e} μια εικόνα {width} × {height}: {e} +@msg an image could not be written: {e} μια εικόνα δεν μπόρεσε να γραφτεί: {e} +@msg an image that couldn't be read was left out παραλείφθηκε μια εικόνα που δεν μπόρεσε να διαβαστεί +@msg can't open “{name}”: {e} δεν είναι δυνατό το άνοιγμα του «{name}»: {e} +@msg cannot align zip entries to {align} bytes δεν είναι δυνατή η στοίχιση των καταχωρίσεων zip σε {align} byte +@msg cannot open the automation {kind} root `{_1}`: {e} δεν είναι δυνατό το άνοιγμα της ρίζας αυτοματισμού ({kind}) `{_1}`: {e} +@msg cannot read profile: {0} δεν είναι δυνατή η ανάγνωση του προφίλ: {0} +@msg color book version {version} isn't supported (only 1) η έκδοση {version} του βιβλίου χρωμάτων δεν υποστηρίζεται (μόνο 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) τα βιβλία χρωμάτων στον χρωματικό χώρο {other} δεν υποστηρίζονται (μόνο RGB, CMYK και Lab) +@msg file name too long to zip: {_1} το όνομα αρχείου είναι πολύ μεγάλο για συμπίεση zip: {_1} +@msg file name too long to zip: {name} το όνομα αρχείου είναι πολύ μεγάλο για συμπίεση zip: {name} +@msg freeform line {i}: {e} γραμμή ελεύθερης μορφής {i}: {e} +@msg freeform point {i}: {e} σημείο ελεύθερης μορφής {i}: {e} +@msg give `{key}` or `{other}`, not both δώστε `{key}` ή `{other}`, όχι και τα δύο +@msg grayscale profiles can't be working spaces yet τα προφίλ κλίμακας του γκρι δεν μπορούν ακόμη να είναι χρωματικοί χώροι εργασίας +@msg input contains NaN or infinite coordinates η είσοδος περιέχει συντεταγμένες NaN ή άπειρες συντεταγμένες +@msg input path could not be closed η διαδρομή εισόδου δεν μπόρεσε να κλείσει +@msg invalid ICC profile: {0} μη έγκυρο προφίλ ICC: {0} +@msg invalid scale {scale} μη έγκυρη κλίμακα {scale} +@msg its localized names take more than {_1} KB τα τοπικοποιημένα ονόματά του καταλαμβάνουν περισσότερα από {_1} KB +@msg its objects are nested too deep ({e}) τα αντικείμενά του είναι ένθετα σε πολύ μεγάλο βάθος ({e}) +@msg its tag table lists the `{_1}` tag more than once ο πίνακας ετικετών του περιέχει την ετικέτα `{_1}` περισσότερες από μία φορές +@msg knockout groups with too many overlapping translucent elements are written as PDF knockout groups: their translucent elements knock out what they cover fully οι ομάδες διάτρησης με πάρα πολλά επικαλυπτόμενα ημιδιαφανή στοιχεία γράφονται ως ομάδες διάτρησης PDF: τα ημιδιαφανή στοιχεία τους διατρυπούν πλήρως ό,τι καλύπτουν +@msg not a graphic style library: {e} δεν είναι βιβλιοθήκη στυλ γραφικών: {e} +@msg not a swatch library (.vcswatches or .gpl) δεν είναι βιβλιοθήκη δειγμάτων (.vcswatches ή .gpl) +@msg not a swatch library (format `{_1}`) δεν είναι βιβλιοθήκη δειγμάτων (μορφή `{_1}`) +@msg not a swatch library: {e} δεν είναι βιβλιοθήκη δειγμάτων: {e} +@msg not an action set file ({e}) δεν είναι αρχείο συνόλου ενεργειών ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray ο χρωματικός χώρος {other} του προφίλ δεν είναι RGB, CMYK ή Gray +@msg regular expression too large η κανονική έκφραση είναι πολύ μεγάλη +@msg script file is not UTF-8 text: {value} το αρχείο σεναρίου δεν είναι κείμενο UTF-8: {value} +@msg stop {i}: {e} σημείο χρώματος {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) η έκδοση {major}.{minor} του αρχείου ανταλλαγής δειγμάτων δεν υποστηρίζεται (μόνο 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors το βιβλίο χρωμάτων περιέχει περισσότερα από {ASE_MAX_ENTRIES} χρώματα +@msg the color book is cut short το βιβλίο χρωμάτων είναι περικομμένο +@msg the color book's names take more than {_1} MB τα ονόματα του βιβλίου χρωμάτων καταλαμβάνουν περισσότερα από {_1} MB +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution η εικόνα θα ήταν {w} × {h} εικονοστοιχεία· οι εξαγωγές raster περιορίζονται σε {MAX_RASTER_SIDE} εικονοστοιχεία ανά πλευρά και {_1} μεγαεικονοστοιχεία: μειώστε την κλίμακα ή την ανάλυση +@msg the optimised image can't be read back: {e} η βελτιστοποιημένη εικόνα δεν μπορεί να διαβαστεί ξανά: {e} +@msg the pixel buffer doesn't match the image size η προσωρινή μνήμη εικονοστοιχείων δεν αντιστοιχεί στο μέγεθος της εικόνας +@msg the preview: {e} η προεπισκόπηση: {e} +@msg the request may not read the New Document profiles folder το αίτημα δεν επιτρέπεται να διαβάσει τον φάκελο προφίλ νέου εγγράφου +@msg the shapes are too degenerate to combine τα σχήματα είναι πολύ εκφυλισμένα για να συνδυαστούν +@msg the shapes cross themselves and each other too often to combine τα σχήματα τέμνουν τον εαυτό τους και το ένα το άλλο πάρα πολλές φορές για να συνδυαστούν +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups το αρχείο ανταλλαγής δειγμάτων περιέχει περισσότερα από {ASE_MAX_ENTRIES} χρώματα και ομάδες +@msg the swatch exchange file is cut short το αρχείο ανταλλαγής δειγμάτων είναι περικομμένο +@msg the {_1} plate can't be read: {e} η πλάκα {_1} δεν μπορεί να διαβαστεί: {e} +@msg this binary DXF file is damaged or cut short αυτό το δυαδικό αρχείο DXF είναι κατεστραμμένο ή περικομμένο +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that αυτό είναι σχέδιο DWG και δεν μπορεί να διαβαστεί: αποθηκεύστε το ως DXF από την εφαρμογή CAD και ανοίξτε το +@msg too many classes υπερβολικά πολλές κλάσεις +@msg too many groups υπερβολικά πολλές ομάδες +@msg too many layers for PSD (at most 32767) πάρα πολλά επίπεδα για PSD (το πολύ 32767) +@msg too many swatch exchange blocks πάρα πολλά μπλοκ στο αρχείο ανταλλαγής δειγμάτων +@msg unknown colour profile: {0} άγνωστο προφίλ χρώματος: {0} +@msg {_1} clashes with a built-in profile name το {_1} συγκρούεται με το όνομα ενός ενσωματωμένου προφίλ +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution τα {_1} × {_2} εικονοστοιχεία είναι υπερβολικό μέγεθος για GIF (το πολύ 65535 εικονοστοιχεία ανά πλευρά): μειώστε την ανάλυση +@msg {_1}: no usable transform to/from sRGB {_1}: δεν υπάρχει χρησιμοποιήσιμος μετασχηματισμός προς/από sRGB +@msg {name} is not a CMYK profile το {name} δεν είναι προφίλ CMYK +@msg {name} is not an RGB profile το {name} δεν είναι προφίλ RGB +@msg {path}: could not be read {path}: δεν ήταν δυνατή η ανάγνωση +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution τα {width} × {height} εικονοστοιχεία είναι υπερβολικό μέγεθος για JPEG (το πολύ 65535 εικονοστοιχεία ανά πλευρά): μειώστε την ανάλυση +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution τα {width} × {height} εικονοστοιχεία είναι υπερβολικό μέγεθος για Targa (το πολύ 65535 εικονοστοιχεία ανά πλευρά): μειώστε την ανάλυση +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution τα {w} × {h} εικονοστοιχεία είναι υπερβολικό μέγεθος για PSD (το πολύ {MAX_SIDE} εικονοστοιχεία ανά πλευρά): μειώστε την ανάλυση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3de5287c48..3e8bd23752 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -7332,6 +7332,122 @@ breakText Glyphs Glifos @msg only an exporter or an importer declares `extensions` solo un plugin de exportación o de importación declara `extensions` @msg an importer always gets an import's budget: leave `budget` out un plugin de importación siempre recibe el presupuesto de una importación: omita `budget` +# Messages of the image encoders, color profiles, swatch exchange files and color books, 3D and Revolve, path operations, DXF import, the script engine and parameter checks. +@msg 3D effects require finite coordinates within 100 million points Los efectos 3D requieren coordenadas finitas dentro de 100 millones de puntos +@msg 3D effects require one to ten lights Los efectos 3D requieren de una a diez luces +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Profundidad de BMP {_1}: 1, 4, 8, 16, 24 o 32 bits por píxel +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Error al codificar BMP: {width} × {height} píxeles no caben en el formato o no coinciden con el búfer de píxeles +@msg GIF encoding failed: the palette image is malformed Error al codificar GIF: la imagen con paleta está mal formada +@msg GIF encoding failed: {e} Error al codificar GIF: {e} +@msg JPEG colour profile: {e} Perfil de color de JPEG: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size Error al codificar JPEG: el búfer de píxeles no coincide con el tamaño de la imagen +@msg JPEG encoding failed: {e} Error al codificar JPEG: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size Error al codificar PNG: el búfer de píxeles no coincide con el tamaño de la imagen +@msg PNG encoding failed: the palette image is malformed Error al codificar PNG: la imagen con paleta está mal formada +@msg PNG encoding failed: {e} Error al codificar PNG: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Error al codificar PNG: {width} × {height} píxeles no es un tamaño de imagen válido +@msg PSD colour profile: {e} Perfil de color de PSD: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size Error al codificar PSD: el búfer de píxeles no coincide con el tamaño de la imagen +@msg Revolve profile contains invalid coordinates El perfil de Revolución contiene coordenadas no válidas +@msg Revolve profile is too complex El perfil de Revolución es demasiado complejo +@msg Revolve projection is not finite La proyección de Revolución no es finita +@msg Revolve requires finite profile coordinates within 100 million points Revolución requiere coordenadas de perfil finitas dentro de 100 millones de puntos +@msg Revolve surface is too complex; reduce Segments or simplify the profile La superficie de Revolución es demasiado compleja; reduce Segmentos o simplifica el perfil +@msg Revolve surface is too complex; simplify the profile La superficie de Revolución es demasiado compleja; simplifica el perfil +@msg Revolve visibility cannot split the projected boundary La visibilidad de Revolución no puede dividir el contorno proyectado +@msg Revolve visibility creates too many fragments La visibilidad de Revolución crea demasiados fragmentos +@msg Revolve visibility creates too many vector anchors La visibilidad de Revolución crea demasiados puntos de ancla vectoriales +@msg Revolve visibility exceeds its geometry work limit La visibilidad de Revolución supera su límite de trabajo geométrico +@msg Revolve visibility exceeds its spatial index limit La visibilidad de Revolución supera su límite de índice espacial +@msg Revolve visibility has too many boundary crossings La visibilidad de Revolución tiene demasiados cruces de contorno +@msg Revolve visibility has too many input anchors La visibilidad de Revolución tiene demasiados puntos de ancla de entrada +@msg Revolve visibility requires convex cap sectors La visibilidad de Revolución requiere sectores de tapa convexos +@msg Revolve visibility requires finite projected faces La visibilidad de Revolución requiere caras proyectadas finitas +@msg SVG options: {e} Opciones de SVG: {e} +@msg Save for Web settings: {e} Ajustes de Guardar para web: {e} +@msg TIFF colour profile: {e} Perfil de color de TIFF: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size Error al codificar TIFF: el búfer de píxeles no coincide con el tamaño de la imagen +@msg TIFF encoding failed: {e} Error al codificar TIFF: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Profundidad de Targa {_1}: 16, 24 o 32 bits por píxel +@msg Targa encoding failed: the pixel buffer doesn't match the image size Error al codificar Targa: el búfer de píxeles no coincide con el tamaño de la imagen +@msg The 3D projection is not finite La proyección 3D no es finita +@msg The 3D shadow is too complex; turn Shadows off or simplify the art La sombra 3D es demasiado compleja; desactiva Sombras o simplifica la ilustración +@msg The shape contains invalid coordinates La forma contiene coordenadas no válidas +@msg Too many Revolve faces for visibility evaluation Demasiadas caras de Revolución para evaluar la visibilidad +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft lee como máximo {ASE_MAX_ENTRIES} colores y grupos de un archivo de intercambio de muestras, y esta biblioteca tiene {entries} +@msg WebP encoding failed: {e} Error al codificar WebP: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` es smallThumb, mediumThumb, largeThumb, smallList o largeList +@msg a line can't run from a point to itself una línea no puede ir de un punto a sí mismo +@msg a line needs at least two points una línea necesita al menos dos puntos +@msg a number cannot be read no se puede leer un número +@msg a swatch exchange block is too long un bloque de intercambio de muestras es demasiado largo +@msg a swatch exchange name is too long un nombre de intercambio de muestras es demasiado largo +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels una imagen de {sw} × {sh} no se puede cambiar de tamaño a {width} × {height} píxeles +@msg a {width} × {height} image is too large una imagen de {width} × {height} es demasiado grande +@msg a {width} × {height} image: {e} una imagen de {width} × {height}: {e} +@msg an image could not be written: {e} no se pudo escribir una imagen: {e} +@msg an image that couldn't be read was left out se omitió una imagen que no se pudo leer +@msg can't open “{name}”: {e} no se puede abrir «{name}»: {e} +@msg cannot align zip entries to {align} bytes no se pueden alinear las entradas zip a {align} bytes +@msg cannot open the automation {kind} root `{_1}`: {e} no se puede abrir la carpeta raíz de automatización ({kind}) `{_1}`: {e} +@msg cannot read profile: {0} no se puede leer el perfil: {0} +@msg color book version {version} isn't supported (only 1) no se admite la versión {version} del libro de colores (solo 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) no se admiten libros de colores en el espacio de color {other} (solo RGB, CMYK y Lab) +@msg file name too long to zip: {_1} nombre de archivo demasiado largo para comprimirlo en zip: {_1} +@msg file name too long to zip: {name} nombre de archivo demasiado largo para comprimirlo en zip: {name} +@msg freeform line {i}: {e} línea de forma libre {i}: {e} +@msg freeform point {i}: {e} punto de forma libre {i}: {e} +@msg give `{key}` or `{other}`, not both indica `{key}` o `{other}`, no ambos +@msg grayscale profiles can't be working spaces yet los perfiles de escala de grises todavía no pueden ser espacios de trabajo +@msg input contains NaN or infinite coordinates la entrada contiene coordenadas NaN o infinitas +@msg input path could not be closed no se pudo cerrar el trazado de entrada +@msg invalid ICC profile: {0} perfil ICC no válido: {0} +@msg invalid scale {scale} escala no válida {scale} +@msg its localized names take more than {_1} KB sus nombres localizados ocupan más de {_1} KB +@msg its objects are nested too deep ({e}) sus objetos están anidados a demasiada profundidad ({e}) +@msg its tag table lists the `{_1}` tag more than once su tabla de etiquetas incluye la etiqueta `{_1}` más de una vez +@msg not a graphic style library: {e} no es una biblioteca de estilos gráficos: {e} +@msg not a swatch library (.vcswatches or .gpl) no es una biblioteca de muestras (.vcswatches o .gpl) +@msg not a swatch library (format `{_1}`) no es una biblioteca de muestras (formato `{_1}`) +@msg not a swatch library: {e} no es una biblioteca de muestras: {e} +@msg not an action set file ({e}) no es un archivo de conjunto de acciones ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray el espacio de color {other} del perfil no es RGB, CMYK ni Gray +@msg regular expression too large expresión regular demasiado grande +@msg script file is not UTF-8 text: {value} el archivo de script no es texto UTF-8: {value} +@msg stop {i}: {e} parada {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) no se admite la versión {major}.{minor} de intercambio de muestras (solo 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors el libro de colores contiene más de {ASE_MAX_ENTRIES} colores +@msg the color book is cut short el libro de colores está truncado +@msg the color book's names take more than {_1} MB los nombres del libro de colores ocupan más de {_1} MB +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution la imagen mediría {w} × {h} píxeles; las exportaciones de mapa de bits se limitan a {MAX_RASTER_SIDE} píxeles por lado y {_1} megapíxeles: baja la escala o la resolución +@msg the optimised image can't be read back: {e} la imagen optimizada no se puede volver a leer: {e} +@msg the pixel buffer doesn't match the image size el búfer de píxeles no coincide con el tamaño de la imagen +@msg the preview: {e} la previsualización: {e} +@msg the request may not read the New Document profiles folder la solicitud no puede leer la carpeta de perfiles de Nuevo documento +@msg the shapes are too degenerate to combine las formas están demasiado degeneradas para combinarlas +@msg the shapes cross themselves and each other too often to combine las formas se cruzan consigo mismas y entre sí demasiadas veces para combinarlas +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups el archivo de intercambio de muestras contiene más de {ASE_MAX_ENTRIES} colores y grupos +@msg the swatch exchange file is cut short el archivo de intercambio de muestras está truncado +@msg the {_1} plate can't be read: {e} no se puede leer la plancha {_1}: {e} +@msg this binary DXF file is damaged or cut short este archivo DXF binario está dañado o truncado +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that este es un dibujo DWG, que no se puede leer: guárdalo desde la aplicación de CAD como DXF y abre ese archivo +@msg too many classes demasiadas clases +@msg too many groups demasiados grupos +@msg too many layers for PSD (at most 32767) demasiadas capas para PSD (como máximo 32767) +@msg too many swatch exchange blocks demasiados bloques de intercambio de muestras +@msg unknown colour profile: {0} perfil de color desconocido: {0} +@msg {_1} clashes with a built-in profile name {_1} coincide con el nombre de un perfil integrado +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} píxeles es demasiado grande para GIF (como máximo 65535 píxeles por lado): baja la resolución +@msg {_1}: no usable transform to/from sRGB {_1}: no hay ninguna transformación utilizable a sRGB ni desde sRGB +@msg {name} is not a CMYK profile {name} no es un perfil CMYK +@msg {name} is not an RGB profile {name} no es un perfil RGB +@msg {path}: could not be read {path}: no se pudo leer +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} píxeles es demasiado grande para JPEG (como máximo 65535 píxeles por lado): baja la resolución +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} píxeles es demasiado grande para Targa (como máximo 65535 píxeles por lado): baja la resolución +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} píxeles es demasiado grande para PSD (como máximo {MAX_SIDE} píxeles por lado): baja la resolución + # Copy Troubleshooting Info Copy Troubleshooting Info Copiar información de solución de problemas @msg Copied the troubleshooting info Se copió la información de solución de problemas @@ -7416,6 +7532,12 @@ breakText Glyphs Glifos @msg open the practice sample of `{id}` first (see learn.openSample) abre primero la muestra de práctica de `{id}` (consulta learn.openSample) @msg finish `{id}` first (see learn.finish) termina primero `{id}` (consulta learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` es el último tutorial de su habilidad + Reset progress Restablecer progreso + Mark Tutorial Step Marcar paso del tutorial + Reset Learn Progress Restablecer progreso de aprendizaje +@msg missing `step` (see learn.show) falta `step` (consulta learn.show) +@msg `step` must be a step number from 0 to {last} `step` debe ser un número de paso de 0 a {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` está terminado (consulta learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Actualizar efectos aplazados diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d06a0bb02b..d408220f47 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -7507,6 +7507,12 @@ breakText Glyphs Merkit @msg open the practice sample of `{id}` first (see learn.openSample) avaa ensin opetusohjelman `{id}` harjoitustiedosto (ks. learn.openSample) @msg finish `{id}` first (see learn.finish) viimeistele ensin `{id}` (ks. learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` on taitonsa viimeinen opetusohjelma + Reset progress Palauta edistyminen + Mark Tutorial Step Merkitse opetusohjelman vaihe + Reset Learn Progress Palauta oppimisen edistyminen +@msg missing `step` (see learn.show) `step` puuttuu (ks. learn.show) +@msg `step` must be a step number from 0 to {last} `step` on oltava vaiheen numero, vähintään 0 ja enintään {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` on viimeistelty (ks. learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Päivitä lykätyt tehosteet @@ -7536,3 +7542,122 @@ breakText Glyphs Merkit Grommets Purjerenkaat Grommet Inset Purjerenkaiden etäisyys reunasta Diameter Halkaisija + +# Messages of the effects, 3D, image and PDF export, color profiles, swatch exchange files, color books and DXF import. +@msg 3D effects require finite coordinates within 100 million points 3D-tehosteet vaativat äärelliset koordinaatit 100 miljoonan pisteen rajoissa +@msg 3D effects require one to ten lights 3D-tehosteet vaativat 1–10 valonlähdettä +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP-värisyvyys {_1}: 1, 4, 8, 16, 24 tai 32 bittiä pikseliä kohti +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP-koodaus epäonnistui: {width} × {height} pikseliä ei mahdu tiedostomuotoon tai pikselipuskuriin +@msg GIF encoding failed: the palette image is malformed GIF-koodaus epäonnistui: palettikuva on virheellinen +@msg GIF encoding failed: {e} GIF-koodaus epäonnistui: {e} +@msg JPEG colour profile: {e} JPEG-väriprofiili: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG-koodaus epäonnistui: pikselipuskuri ei vastaa kuvan kokoa +@msg JPEG encoding failed: {e} JPEG-koodaus epäonnistui: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size PNG-koodaus epäonnistui: pikselipuskuri ei vastaa kuvan kokoa +@msg PNG encoding failed: the palette image is malformed PNG-koodaus epäonnistui: palettikuva on virheellinen +@msg PNG encoding failed: {e} PNG-koodaus epäonnistui: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG-koodaus epäonnistui: {width} × {height} pikseliä ei ole kelvollinen kuvakoko +@msg PSD colour profile: {e} PSD-väriprofiili: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD-koodaus epäonnistui: pikselipuskuri ei vastaa kuvan kokoa +@msg Revolve profile contains invalid coordinates Pyöräytä-tehosteen profiilissa on virheellisiä koordinaatteja +@msg Revolve profile is too complex Pyöräytä-tehosteen profiili on liian monimutkainen +@msg Revolve projection is not finite Pyöräytä-tehosteen projektio ei ole äärellinen +@msg Revolve requires finite profile coordinates within 100 million points Pyöräytä vaatii äärelliset profiilin koordinaatit 100 miljoonan pisteen rajoissa +@msg Revolve surface is too complex; reduce Segments or simplify the profile Pyöräytä-tehosteen pinta on liian monimutkainen; pienennä Segmentit-arvoa tai yksinkertaista profiilia +@msg Revolve surface is too complex; simplify the profile Pyöräytä-tehosteen pinta on liian monimutkainen; yksinkertaista profiilia +@msg Revolve visibility cannot split the projected boundary Pyöräytä-tehosteen näkyvyyslaskenta ei voi jakaa projisoitua reunaa +@msg Revolve visibility creates too many fragments Pyöräytä-tehosteen näkyvyyslaskenta tuottaa liikaa osia +@msg Revolve visibility creates too many vector anchors Pyöräytä-tehosteen näkyvyyslaskenta tuottaa liikaa vektorin ankkuripisteitä +@msg Revolve visibility exceeds its geometry work limit Pyöräytä-tehosteen näkyvyyslaskenta ylittää geometrialaskennan työmäärän rajan +@msg Revolve visibility exceeds its spatial index limit Pyöräytä-tehosteen näkyvyyslaskenta ylittää paikkaindeksin rajan +@msg Revolve visibility has too many boundary crossings Pyöräytä-tehosteen näkyvyyslaskennassa on liikaa reunan ylityksiä +@msg Revolve visibility has too many input anchors Pyöräytä-tehosteen näkyvyyslaskennassa on liikaa syötteen ankkuripisteitä +@msg Revolve visibility requires convex cap sectors Pyöräytä-tehosteen näkyvyyslaskenta vaatii kuperat päätysektorit +@msg Revolve visibility requires finite projected faces Pyöräytä-tehosteen näkyvyyslaskenta vaatii äärelliset projisoidut tahkot +@msg SVG options: {e} SVG-asetukset: {e} +@msg Save for Web settings: {e} Verkkotallennuksen asetukset: {e} +@msg TIFF colour profile: {e} TIFF-väriprofiili: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF-koodaus epäonnistui: pikselipuskuri ei vastaa kuvan kokoa +@msg TIFF encoding failed: {e} TIFF-koodaus epäonnistui: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa-värisyvyys {_1}: 16, 24 tai 32 bittiä pikseliä kohti +@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa-koodaus epäonnistui: pikselipuskuri ei vastaa kuvan kokoa +@msg The 3D projection is not finite 3D-projektio ei ole äärellinen +@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D-varjo on liian monimutkainen; poista Varjot käytöstä tai yksinkertaista kuvitusta +@msg The shape contains invalid coordinates Muodossa on virheellisiä koordinaatteja +@msg Too many Revolve faces for visibility evaluation Pyöräytä-tehosteessa on liian monta tahkoa näkyvyyden arviointiin +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft lukee värimallien vaihtotiedostosta enintään {ASE_MAX_ENTRIES} väriä ja ryhmää, ja tässä kirjastossa niitä on {entries} +@msg WebP encoding failed: {e} WebP-koodaus epäonnistui: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `index` must be a whole number `index` on oltava kokonaisluku +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` on smallThumb, mediumThumb, largeThumb, smallList tai largeList +@msg a line can't run from a point to itself viiva ei voi kulkea pisteestä samaan pisteeseen +@msg a line needs at least two points viiva tarvitsee vähintään kaksi pistettä +@msg a number cannot be read lukua ei voi lukea +@msg a swatch exchange block is too long värimallien vaihtotiedoston lohko on liian pitkä +@msg a swatch exchange name is too long värimallien vaihtotiedoston nimi on liian pitkä +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels {sw} × {sh} -kuvan kokoa ei voi muuttaa kokoon {width} × {height} pikseliä +@msg a {width} × {height} image is too large {width} × {height} -kuva on liian suuri +@msg a {width} × {height} image: {e} {width} × {height} -kuva: {e} +@msg an image could not be written: {e} kuvaa ei voitu kirjoittaa: {e} +@msg an image that couldn't be read was left out kuva, jota ei voitu lukea, jätettiin pois +@msg can't open “{name}”: {e} tiedostoa ”{name}” ei voi avata: {e} +@msg cannot align zip entries to {align} bytes zip-merkintöjä ei voi tasata {align} tavun rajoihin +@msg cannot open the automation {kind} root `{_1}`: {e} automaation {kind}-juurikansiota `{_1}` ei voi avata: {e} +@msg cannot read profile: {0} profiilia ei voi lukea: {0} +@msg color book version {version} isn't supported (only 1) värikirjan versiota {version} ei tueta (vain 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) väriavaruuden {other} värikirjoja ei tueta (vain RGB, CMYK ja Lab) +@msg file name too long to zip: {_1} tiedostonimi on liian pitkä zip-pakettiin: {_1} +@msg file name too long to zip: {name} tiedostonimi on liian pitkä zip-pakettiin: {name} +@msg freeform line {i}: {e} vapaamuotoinen viiva {i}: {e} +@msg freeform point {i}: {e} vapaamuotoinen piste {i}: {e} +@msg give `{key}` or `{other}`, not both anna `{key}` tai `{other}`, ei molempia +@msg grayscale profiles can't be working spaces yet harmaasävyprofiileja ei voi vielä käyttää työväriavaruuksina +@msg input contains NaN or infinite coordinates syötteessä on NaN-arvoja tai äärettömiä koordinaatteja +@msg input path could not be closed syötepolkua ei voitu sulkea +@msg invalid ICC profile: {0} virheellinen ICC-profiili: {0} +@msg invalid scale {scale} virheellinen skaalaus {scale} +@msg its localized names take more than {_1} KB sen lokalisoidut nimet vievät yli {_1} KB +@msg its objects are nested too deep ({e}) sen objektit ovat sisäkkäin liian monella tasolla ({e}) +@msg its tag table lists the `{_1}` tag more than once sen tunnistetaulukossa `{_1}`-tunniste on useammin kuin kerran +@msg knockout groups with too many overlapping translucent elements are written as PDF knockout groups: their translucent elements knock out what they cover fully poistoryhmät, joissa on liian monta päällekkäistä läpikuultavaa elementtiä, kirjoitetaan PDF-poistoryhminä: niiden läpikuultavat elementit poistavat peittämänsä alueen kokonaan +@msg knockout groups with brushed strokes over other translucent elements are written as PDF knockout groups: their translucent elements knock out what they cover fully poistoryhmät, joissa on sivellinviivoja muiden läpikuultavien elementtien päällä, kirjoitetaan PDF-poistoryhminä: niiden läpikuultavat elementit poistavat peittämänsä alueen kokonaan +@msg not a graphic style library: {e} ei grafiikkatyylikirjasto: {e} +@msg not a swatch library (.vcswatches or .gpl) ei värimallikirjasto (.vcswatches tai .gpl) +@msg not a swatch library (format `{_1}`) ei värimallikirjasto (muoto `{_1}`) +@msg not a swatch library: {e} ei värimallikirjasto: {e} +@msg not an action set file ({e}) ei toimintosarjatiedosto ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray profiilin väriavaruus {other} ei ole RGB, CMYK tai Gray +@msg regular expression too large säännöllinen lauseke on liian suuri +@msg script file is not UTF-8 text: {value} komentosarjatiedosto ei ole UTF-8-tekstiä: {value} +@msg stop {i}: {e} väripiste {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) värimallien vaihtotiedoston versiota {major}.{minor} ei tueta (vain 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors värikirjassa on yli {ASE_MAX_ENTRIES} väriä +@msg the color book is cut short värikirja on katkennut +@msg the color book's names take more than {_1} MB värikirjan nimet vievät yli {_1} MB +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution kuvasta tulisi {w} × {h} pikseliä; rasteriviennin raja on {MAX_RASTER_SIDE} pikseliä sivua kohti ja {_1} megapikseliä: pienennä skaalausta tai tarkkuutta +@msg the optimised image can't be read back: {e} optimoitua kuvaa ei voi lukea takaisin: {e} +@msg the pixel buffer doesn't match the image size pikselipuskuri ei vastaa kuvan kokoa +@msg the preview: {e} esikatselu: {e} +@msg the request may not read the New Document profiles folder pyyntö ei saa lukea uuden asiakirjan profiilien kansiota +@msg the shapes are too degenerate to combine muodot ovat liian surkastuneita yhdistettäviksi +@msg the shapes cross themselves and each other too often to combine muodot leikkaavat itseään ja toisiaan liian usein yhdistettäviksi +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups värimallien vaihtotiedostossa on yli {ASE_MAX_ENTRIES} väriä ja ryhmää +@msg the swatch exchange file is cut short värimallien vaihtotiedosto on katkennut +@msg the {_1} plate can't be read: {e} {_1}-painolevyä ei voi lukea: {e} +@msg this binary DXF file is damaged or cut short tämä binäärinen DXF-tiedosto on vioittunut tai katkennut +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that tämä on DWG-piirustus, jota ei voi lukea: tallenna se CAD-sovelluksesta DXF-muodossa ja avaa se +@msg too many classes liikaa luokkia +@msg too many groups liikaa ryhmiä +@msg too many layers for PSD (at most 32767) liikaa tasoja PSD-tiedostoon (enintään 32767) +@msg too many swatch exchange blocks liikaa värimallien vaihtotiedoston lohkoja +@msg unknown colour profile: {0} tuntematon väriprofiili: {0} +@msg {_1} clashes with a built-in profile name {_1} on ristiriidassa sisäisen profiilin nimen kanssa +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} pikseliä on liian suuri GIF-kuvalle (enintään 65535 pikseliä sivua kohti): pienennä tarkkuutta +@msg {_1}: no usable transform to/from sRGB {_1}: ei käyttökelpoista muunnosta sRGB:hen tai sRGB:stä +@msg {name} is not a CMYK profile {name} ei ole CMYK-profiili +@msg {name} is not an RGB profile {name} ei ole RGB-profiili +@msg {path}: could not be read {path}: ei voitu lukea +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pikseliä on liian suuri JPEG-kuvalle (enintään 65535 pikseliä sivua kohti): pienennä tarkkuutta +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pikseliä on liian suuri Targa-kuvalle (enintään 65535 pikseliä sivua kohti): pienennä tarkkuutta +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pikseliä on liian suuri PSD-kuvalle (enintään {MAX_SIDE} pikseliä sivua kohti): pienennä tarkkuutta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3dfd5ad5d9..318bbbeb44 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7467,6 +7467,12 @@ breakText Glyphs Glyphes @msg open the practice sample of `{id}` first (see learn.openSample) ouvrez d’abord le fichier d’exercice de `{id}` (voir learn.openSample) @msg finish `{id}` first (see learn.finish) terminez d’abord `{id}` (voir learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` est le dernier tutoriel de sa compétence + Reset progress Réinitialiser la progression + Mark Tutorial Step Marquer une étape du tutoriel + Reset Learn Progress Réinitialiser la progression d’apprentissage +@msg missing `step` (see learn.show) `step` manquant (voir learn.show) +@msg `step` must be a step number from 0 to {last} `step` doit être un numéro d’étape de 0 à {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` est terminé (voir learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Actualiser les effets différés @@ -9300,3 +9306,119 @@ breakText Glyphs Glyphes Grommets Œillets Grommet Inset Distance des œillets au bord Diameter Diamètre + +# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. +@msg 3D effects require finite coordinates within 100 million points Les effets 3D exigent des coordonnées finies comprises dans 100 millions de points +@msg 3D effects require one to ten lights Les effets 3D exigent d’une à dix lumières +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Profondeur BMP {_1} : 1, 4, 8, 16, 24 ou 32 bits par pixel +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Échec de l’encodage BMP : {width} × {height} pixels ne conviennent ni au format ni au tampon de pixels +@msg GIF encoding failed: the palette image is malformed Échec de l’encodage GIF : l’image à palette est mal formée +@msg GIF encoding failed: {e} Échec de l’encodage GIF : {e} +@msg JPEG colour profile: {e} Profil colorimétrique JPEG : {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size Échec de l’encodage JPEG : le tampon de pixels ne correspond pas à la taille de l’image +@msg JPEG encoding failed: {e} Échec de l’encodage JPEG : {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size Échec de l’encodage PNG : le tampon de pixels ne correspond pas à la taille de l’image +@msg PNG encoding failed: the palette image is malformed Échec de l’encodage PNG : l’image à palette est mal formée +@msg PNG encoding failed: {e} Échec de l’encodage PNG : {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Échec de l’encodage PNG : {width} × {height} pixels n’est pas une taille d’image valide +@msg PSD colour profile: {e} Profil colorimétrique PSD : {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size Échec de l’encodage PSD : le tampon de pixels ne correspond pas à la taille de l’image +@msg Revolve profile contains invalid coordinates Le profil de révolution contient des coordonnées non valides +@msg Revolve profile is too complex Le profil de révolution est trop complexe +@msg Revolve projection is not finite La projection de la révolution n’est pas finie +@msg Revolve requires finite profile coordinates within 100 million points La révolution exige des coordonnées de profil finies comprises dans 100 millions de points +@msg Revolve surface is too complex; reduce Segments or simplify the profile La surface de révolution est trop complexe ; réduisez Segments ou simplifiez le profil +@msg Revolve surface is too complex; simplify the profile La surface de révolution est trop complexe ; simplifiez le profil +@msg Revolve visibility cannot split the projected boundary La visibilité de la révolution ne peut pas diviser le contour projeté +@msg Revolve visibility creates too many fragments La visibilité de la révolution crée trop de fragments +@msg Revolve visibility creates too many vector anchors La visibilité de la révolution crée trop de points d’ancrage vectoriels +@msg Revolve visibility exceeds its geometry work limit La visibilité de la révolution dépasse sa limite de calcul géométrique +@msg Revolve visibility exceeds its spatial index limit La visibilité de la révolution dépasse sa limite d’index spatial +@msg Revolve visibility has too many boundary crossings La visibilité de la révolution compte trop de croisements de contour +@msg Revolve visibility has too many input anchors La visibilité de la révolution compte trop de points d’ancrage en entrée +@msg Revolve visibility requires convex cap sectors La visibilité de la révolution exige des secteurs de fermeture convexes +@msg Revolve visibility requires finite projected faces La visibilité de la révolution exige des faces projetées finies +@msg SVG options: {e} Options SVG : {e} +@msg Save for Web settings: {e} Réglages d’Enregistrer pour le web : {e} +@msg TIFF colour profile: {e} Profil colorimétrique TIFF : {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size Échec de l’encodage TIFF : le tampon de pixels ne correspond pas à la taille de l’image +@msg TIFF encoding failed: {e} Échec de l’encodage TIFF : {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Profondeur Targa {_1} : 16, 24 ou 32 bits par pixel +@msg Targa encoding failed: the pixel buffer doesn't match the image size Échec de l’encodage Targa : le tampon de pixels ne correspond pas à la taille de l’image +@msg The 3D projection is not finite La projection 3D n’est pas finie +@msg The 3D shadow is too complex; turn Shadows off or simplify the art L’ombre 3D est trop complexe ; désactivez Ombres ou simplifiez l’illustration +@msg The shape contains invalid coordinates La forme contient des coordonnées non valides +@msg Too many Revolve faces for visibility evaluation Trop de faces de révolution pour évaluer la visibilité +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft lit au plus {ASE_MAX_ENTRIES} couleurs et groupes d’un fichier d’échange de nuances, et cette bibliothèque en contient {entries} +@msg WebP encoding failed: {e} Échec de l’encodage WebP : {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}` : {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` vaut smallThumb, mediumThumb, largeThumb, smallList ou largeList +@msg a line can't run from a point to itself une ligne ne peut pas relier un point à lui-même +@msg a line needs at least two points une ligne nécessite au moins deux points +@msg a number cannot be read un nombre ne peut pas être lu +@msg a swatch exchange block is too long un bloc du fichier d’échange de nuances est trop long +@msg a swatch exchange name is too long un nom du fichier d’échange de nuances est trop long +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels une image de {sw} × {sh} ne peut pas être redimensionnée à {width} × {height} pixels +@msg a {width} × {height} image is too large une image de {width} × {height} est trop grande +@msg a {width} × {height} image: {e} une image de {width} × {height} : {e} +@msg an image could not be written: {e} une image n’a pas pu être écrite : {e} +@msg an image that couldn't be read was left out une image illisible a été omise +@msg can't open “{name}”: {e} impossible d’ouvrir « {name} » : {e} +@msg cannot align zip entries to {align} bytes impossible d’aligner les entrées zip sur {align} octets +@msg cannot open the automation {kind} root `{_1}`: {e} impossible d’ouvrir le dossier racine d’automatisation ({kind}) `{_1}` : {e} +@msg cannot read profile: {0} impossible de lire le profil : {0} +@msg color book version {version} isn't supported (only 1) la version {version} du carnet de couleurs n’est pas prise en charge (seulement 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) les carnets de couleurs dans l’espace colorimétrique {other} ne sont pas pris en charge (seulement RVB, CMJN et Lab) +@msg file name too long to zip: {_1} nom de fichier trop long pour le zip : {_1} +@msg file name too long to zip: {name} nom de fichier trop long pour le zip : {name} +@msg freeform line {i}: {e} ligne libre {i} : {e} +@msg freeform point {i}: {e} point libre {i} : {e} +@msg give `{key}` or `{other}`, not both indiquez `{key}` ou `{other}`, pas les deux +@msg grayscale profiles can't be working spaces yet les profils en niveaux de gris ne peuvent pas encore servir d’espaces de travail +@msg input contains NaN or infinite coordinates l’entrée contient des coordonnées NaN ou infinies +@msg input path could not be closed le tracé d’entrée n’a pas pu être fermé +@msg invalid ICC profile: {0} profil ICC non valide : {0} +@msg invalid scale {scale} échelle non valide {scale} +@msg its localized names take more than {_1} KB ses noms localisés occupent plus de {_1} Ko +@msg its objects are nested too deep ({e}) ses objets sont trop profondément imbriqués ({e}) +@msg its tag table lists the `{_1}` tag more than once sa table de balises répertorie la balise `{_1}` plus d’une fois +@msg not a graphic style library: {e} ce n’est pas une bibliothèque de styles graphiques : {e} +@msg not a swatch library (.vcswatches or .gpl) ce n’est pas une bibliothèque de nuances (.vcswatches ou .gpl) +@msg not a swatch library (format `{_1}`) ce n’est pas une bibliothèque de nuances (format `{_1}`) +@msg not a swatch library: {e} ce n’est pas une bibliothèque de nuances : {e} +@msg not an action set file ({e}) ce n’est pas un fichier de jeu de scripts ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray l’espace colorimétrique {other} du profil n’est pas RVB, CMJN ou Niveaux de gris +@msg regular expression too large expression régulière trop volumineuse +@msg script file is not UTF-8 text: {value} le fichier de script n’est pas du texte UTF-8 : {value} +@msg stop {i}: {e} étape de dégradé {i} : {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) la version {major}.{minor} du fichier d’échange de nuances n’est pas prise en charge (seulement 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors le carnet de couleurs contient plus de {ASE_MAX_ENTRIES} couleurs +@msg the color book is cut short le carnet de couleurs est tronqué +@msg the color book's names take more than {_1} MB les noms du carnet de couleurs occupent plus de {_1} Mo +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution l’image mesurerait {w} × {h} pixels ; les exports en pixels sont limités à {MAX_RASTER_SIDE} pixels par côté et à {_1} mégapixels : réduisez l’échelle ou la résolution +@msg the optimised image can't be read back: {e} l’image optimisée ne peut pas être relue : {e} +@msg the pixel buffer doesn't match the image size le tampon de pixels ne correspond pas à la taille de l’image +@msg the preview: {e} l’aperçu : {e} +@msg the request may not read the New Document profiles folder la requête n’est pas autorisée à lire le dossier des profils de nouveau document +@msg the shapes are too degenerate to combine les formes sont trop dégénérées pour être combinées +@msg the shapes cross themselves and each other too often to combine les formes se croisent elles-mêmes et entre elles trop souvent pour être combinées +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups le fichier d’échange de nuances contient plus de {ASE_MAX_ENTRIES} couleurs et groupes +@msg the swatch exchange file is cut short le fichier d’échange de nuances est tronqué +@msg the {_1} plate can't be read: {e} la plaque {_1} ne peut pas être lue : {e} +@msg too many classes trop de classes +@msg too many groups trop de groupes +@msg too many layers for PSD (at most 32767) trop de calques pour le PSD (32767 au plus) +@msg too many swatch exchange blocks trop de blocs dans le fichier d’échange de nuances +@msg unknown colour profile: {0} profil colorimétrique inconnu : {0} +@msg {_1} clashes with a built-in profile name {_1} entre en conflit avec le nom d’un profil intégré +@msg {_1}: no usable transform to/from sRGB {_1} : aucune transformation utilisable vers ou depuis sRGB +@msg {name} is not a CMYK profile {name} n’est pas un profil CMJN +@msg {name} is not an RGB profile {name} n’est pas un profil RVB +@msg {path}: could not be read {path} : lecture impossible +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pixels, c’est trop grand pour le JPEG (65535 pixels par côté au plus) : réduisez la résolution +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pixels, c’est trop grand pour le Targa (65535 pixels par côté au plus) : réduisez la résolution +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pixels, c’est trop grand pour le PSD ({MAX_SIDE} pixels par côté au plus) : réduisez la résolution +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that c’est un dessin DWG, qui ne peut pas être lu : enregistrez-le depuis l’application de CAO au format DXF et ouvrez ce fichier +@msg this binary DXF file is damaged or cut short ce fichier DXF binaire est endommagé ou tronqué +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} pixels, c’est trop grand pour le GIF (65535 pixels par côté au plus) : réduisez la résolution diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3c77aa0a99..78c0a9a7eb 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8863,6 +8863,12 @@ theme Appearance अपीयरेंस @msg open the practice sample of `{id}` first (see learn.openSample) पहले `{id}` का अभ्यास नमूना खोलें (learn.openSample देखें) @msg finish `{id}` first (see learn.finish) पहले `{id}` पूरा करें (learn.finish देखें) @msg `{id}` is the last tutorial in its skill `{id}` अपने कौशल का अंतिम ट्यूटोरियल है + Reset progress प्रगति रीसेट करें + Mark Tutorial Step ट्यूटोरियल चरण चिह्नित करें + Reset Learn Progress सीखने की प्रगति रीसेट करें +@msg missing `step` (see learn.show) लापता `step` (learn.show देखें) +@msg `step` must be a step number from 0 to {last} `step` 0 से {last} तक की चरण संख्या होनी चाहिए +@msg `{id}` is finished (see learn.resetProgress) `{id}` पूरा हो चुका है (learn.resetProgress देखें) # Deferred plug-in effects Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c4cdb9d544..7f2f03afb2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7456,6 +7456,12 @@ breakText Glyphs Glifák @msg open the practice sample of `{id}` first (see learn.openSample) előbb nyissa meg a gyakorló mintafájlt ehhez: `{id}` (lásd: learn.openSample) @msg finish `{id}` first (see learn.finish) előbb fejezze be ezt: `{id}` (lásd: learn.finish) @msg `{id}` is the last tutorial in its skill a készsége utolsó oktatóanyaga: `{id}` + Reset progress Előrehaladás visszaállítása + Mark Tutorial Step Oktatóanyag lépésének megjelölése + Reset Learn Progress Tanulási előrehaladás visszaállítása +@msg missing `step` (see learn.show) hiányzik: `step` (lásd: learn.show) +@msg `step` must be a step number from 0 to {last} a `step` értéke 0 és {last} közötti lépésszám legyen +@msg `{id}` is finished (see learn.resetProgress) befejezve: `{id}` (lásd: learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Elhalasztott effektusok frissítése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 208877f673..c7c6b956db 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6880,6 +6880,12 @@ breakText Glyphs Glif @msg open the practice sample of `{id}` first (see learn.openSample) buka contoh latihan `{id}` terlebih dahulu (lihat learn.openSample) @msg finish `{id}` first (see learn.finish) selesaikan `{id}` terlebih dahulu (lihat learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` adalah tutorial terakhir dalam keterampilannya + Reset progress Atur ulang kemajuan + Mark Tutorial Step Tandai langkah tutorial + Reset Learn Progress Atur ulang kemajuan belajar +@msg missing `step` (see learn.show) `step` tidak ada (lihat learn.show) +@msg `step` must be a step number from 0 to {last} `step` harus berupa nomor langkah dari 0 sampai {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` sudah selesai (lihat learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Segarkan efek yang ditunda diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 534a7a181f..f832a5baf3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -7102,6 +7102,12 @@ breakText Glyphs Glifi @msg open the practice sample of `{id}` first (see learn.openSample) apri prima l’esempio di esercitazione di `{id}` (vedi learn.openSample) @msg finish `{id}` first (see learn.finish) termina prima `{id}` (vedi learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` è l’ultimo tutorial della sua competenza + Reset progress Reimposta i progressi + Mark Tutorial Step Segna il passaggio del tutorial + Reset Learn Progress Reimposta i progressi di apprendimento +@msg missing `step` (see learn.show) manca `step` (vedi learn.show) +@msg `step` must be a step number from 0 to {last} `step` deve essere un numero di passaggio da 0 a {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` è terminato (vedi learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Aggiorna effetti rimandati diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 98a188ee02..3abb388f49 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -7168,6 +7168,12 @@ breakText Glyphs 字形 @msg open the practice sample of `{id}` first (see learn.openSample) 先に `{id}` の練習用サンプルを開いてください (learn.openSample を参照) @msg finish `{id}` first (see learn.finish) 先に `{id}` を完了してください (learn.finish を参照) @msg `{id}` is the last tutorial in its skill `{id}` はこのスキルの最後のチュートリアルです + Reset progress 進行状況をリセット + Mark Tutorial Step チュートリアルの手順をマーク + Reset Learn Progress 学習の進行状況をリセット +@msg missing `step` (see learn.show) `step` が指定されていません (learn.show を参照) +@msg `step` must be a step number from 0 to {last} `step` は 0 から {last} までの手順番号にしてください +@msg `{id}` is finished (see learn.resetProgress) `{id}` は完了しています (learn.resetProgress を参照) # Deferred plug-in effects Refresh Deferred Effects 保留中の効果を更新 @@ -7197,3 +7203,131 @@ breakText Glyphs 字形 Grommets ハトメ Grommet Inset 端からハトメまでの距離 Diameter 直径 + +# Messages no catalog translated yet: image export and color profiles, color books and swatch exchange files, 3D and Revolve, and script, zip, line and file errors. +@msg 3D effects require finite coordinates within 100 million points 3D 効果には 1 億ポイント以内の有限の座標が必要です +@msg 3D effects require one to ten lights 3D 効果にはライトが 1~10 個必要です +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP の色深度 {_1}: 1、4、8、16、24、32 ビット/ピクセルのいずれかにしてください +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP のエンコードに失敗しました: {width} × {height} ピクセルは形式またはピクセルバッファーに収まりません +@msg GIF encoding failed: the palette image is malformed GIF のエンコードに失敗しました: パレット画像の形式が正しくありません +@msg GIF encoding failed: {e} GIF のエンコードに失敗しました: {e} +@msg JPEG colour profile: {e} JPEG のカラープロファイル: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG のエンコードに失敗しました: ピクセルバッファーが画像サイズと一致しません +@msg JPEG encoding failed: {e} JPEG のエンコードに失敗しました: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size PNG のエンコードに失敗しました: ピクセルバッファーが画像サイズと一致しません +@msg PNG encoding failed: the palette image is malformed PNG のエンコードに失敗しました: パレット画像の形式が正しくありません +@msg PNG encoding failed: {e} PNG のエンコードに失敗しました: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG のエンコードに失敗しました: {width} × {height} ピクセルは有効な画像サイズではありません +@msg PSD colour profile: {e} PSD のカラープロファイル: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD のエンコードに失敗しました: ピクセルバッファーが画像サイズと一致しません +@msg Revolve profile contains invalid coordinates 回転体のプロファイルに無効な座標が含まれています +@msg Revolve profile is too complex 回転体のプロファイルが複雑すぎます +@msg Revolve projection is not finite 回転体の投影が有限ではありません +@msg Revolve requires finite profile coordinates within 100 million points 回転体には 1 億ポイント以内の有限のプロファイル座標が必要です +@msg Revolve surface is too complex; reduce Segments or simplify the profile 回転体のサーフェスが複雑すぎます。分割数を減らすか、プロファイルを単純化してください +@msg Revolve surface is too complex; simplify the profile 回転体のサーフェスが複雑すぎます。プロファイルを単純化してください +@msg Revolve visibility cannot split the projected boundary 回転体の可視判定で投影された境界線を分割できません +@msg Revolve visibility creates too many fragments 回転体の可視判定で生成される断片が多すぎます +@msg Revolve visibility creates too many vector anchors 回転体の可視判定で生成されるベクトルのアンカーポイントが多すぎます +@msg Revolve visibility exceeds its geometry work limit 回転体の可視判定がジオメトリ計算の上限を超えています +@msg Revolve visibility exceeds its spatial index limit 回転体の可視判定が空間インデックスの上限を超えています +@msg Revolve visibility has too many boundary crossings 回転体の可視判定で境界線の交差が多すぎます +@msg Revolve visibility has too many input anchors 回転体の可視判定に入力されるアンカーポイントが多すぎます +@msg Revolve visibility requires convex cap sectors 回転体の可視判定には凸状のキャップのセクターが必要です +@msg Revolve visibility requires finite projected faces 回転体の可視判定には有限の投影面が必要です +@msg SVG options: {e} SVG オプション: {e} +@msg Save for Web settings: {e} Web 用に保存の設定: {e} +@msg TIFF colour profile: {e} TIFF のカラープロファイル: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF のエンコードに失敗しました: ピクセルバッファーが画像サイズと一致しません +@msg TIFF encoding failed: {e} TIFF のエンコードに失敗しました: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa の色深度 {_1}: 16、24、32 ビット/ピクセルのいずれかにしてください +@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa のエンコードに失敗しました: ピクセルバッファーが画像サイズと一致しません +@msg The 3D projection is not finite 3D の投影が有限ではありません +@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D のシャドウが複雑すぎます。シャドウをオフにするか、アートを単純化してください +@msg The shape contains invalid coordinates シェイプに無効な座標が含まれています +@msg Too many Revolve faces for visibility evaluation 可視判定を行うには回転体の面が多すぎます +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft がスウォッチ交換ファイルから読み込めるカラーとグループは {ASE_MAX_ENTRIES} 個までですが、このライブラリには {entries} 個あります +@msg WebP encoding failed: {e} WebP のエンコードに失敗しました: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` には smallThumb、mediumThumb、largeThumb、smallList、largeList のいずれかを指定してください +@msg a line can't run from a point to itself ラインの始点と終点に同じポイントは指定できません +@msg a line needs at least two points ラインには少なくとも 2 つのポイントが必要です +@msg a number cannot be read 数値を読み取れません +@msg a swatch exchange block is too long スウォッチ交換ファイルのブロックが長すぎます +@msg a swatch exchange name is too long スウォッチ交換ファイルの名前が長すぎます +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels {sw} × {sh} の画像を {width} × {height} ピクセルにサイズ変更できません +@msg a {width} × {height} image is too large {width} × {height} の画像は大きすぎます +@msg a {width} × {height} image: {e} {width} × {height} の画像: {e} +@msg an image could not be written: {e} 画像を書き出せませんでした: {e} +@msg an image that couldn't be read was left out 読み込めなかった画像は除外されました +@msg can't open “{name}”: {e} 「{name}」を開けません: {e} +@msg cannot align zip entries to {align} bytes ZIP のエントリを {align} バイト境界に揃えられません +@msg cannot open the automation {kind} root `{_1}`: {e} 自動化の {kind} ルート `{_1}` を開けません: {e} +@msg cannot read profile: {0} プロファイルを読み込めません: {0} +@msg color book version {version} isn't supported (only 1) カラーブックのバージョン {version} には対応していません (対応しているのは 1 のみ) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) 色空間 {other} のカラーブックには対応していません (対応しているのは RGB、CMYK、Lab のみ) +@msg file name too long to zip: {_1} ファイル名が長すぎて ZIP に格納できません: {_1} +@msg file name too long to zip: {name} ファイル名が長すぎて ZIP に格納できません: {name} +@msg freeform line {i}: {e} フリーフォームのライン {i}: {e} +@msg freeform point {i}: {e} フリーフォームのポイント {i}: {e} +@msg give `{key}` or `{other}`, not both `{key}` と `{other}` のどちらか一方を指定してください (両方は指定できません) +@msg grayscale profiles can't be working spaces yet グレースケールのプロファイルはまだ作業用スペースにできません +@msg input contains NaN or infinite coordinates 入力に NaN または無限大の座標が含まれています +@msg input path could not be closed 入力パスを閉じられませんでした +@msg invalid ICC profile: {0} 無効な ICC プロファイル: {0} +@msg invalid scale {scale} 倍率 {scale} は無効です +@msg its localized names take more than {_1} KB ローカライズされた名前が {_1} KB を超えています +@msg its objects are nested too deep ({e}) オブジェクトの入れ子が深すぎます ({e}) +@msg its tag table lists the `{_1}` tag more than once タグテーブルに `{_1}` タグが複数回記載されています +@msg not a graphic style library: {e} グラフィックスタイルライブラリではありません: {e} +@msg not a swatch library (.vcswatches or .gpl) スウォッチライブラリではありません (.vcswatches または .gpl) +@msg not a swatch library (format `{_1}`) スウォッチライブラリではありません (形式: `{_1}`) +@msg not a swatch library: {e} スウォッチライブラリではありません: {e} +@msg not an action set file ({e}) アクションセットファイルではありません ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray プロファイルの色空間 {other} は RGB、CMYK、グレースケールのいずれでもありません +@msg regular expression too large 正規表現が大きすぎます +@msg script file is not UTF-8 text: {value} スクリプトファイルは UTF-8 テキストではありません: {value} +@msg stop {i}: {e} 分岐点 {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) スウォッチ交換ファイルのバージョン {major}.{minor} には対応していません (対応しているのは 1.x のみ) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors カラーブックのカラーが {ASE_MAX_ENTRIES} 色を超えています +@msg the color book is cut short カラーブックが途中で切れています +@msg the color book's names take more than {_1} MB カラーブックの名前が {_1} MB を超えています +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution 画像が {w} × {h} ピクセルになります。ラスター書き出しの上限は 1 辺 {MAX_RASTER_SIDE} ピクセル、{_1} メガピクセルです: 倍率または解像度を下げてください +@msg the optimised image can't be read back: {e} 最適化した画像を読み戻せません: {e} +@msg the pixel buffer doesn't match the image size ピクセルバッファーが画像サイズと一致しません +@msg the preview: {e} プレビュー: {e} +@msg the request may not read the New Document profiles folder このリクエストには新規ドキュメントプロファイルのフォルダーを読み込む権限がありません +@msg the shapes are too degenerate to combine シェイプの形状がつぶれすぎていて結合できません +@msg the shapes cross themselves and each other too often to combine シェイプ自身やシェイプ同士の交差が多すぎて結合できません +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups スウォッチ交換ファイルのカラーとグループが {ASE_MAX_ENTRIES} 個を超えています +@msg the swatch exchange file is cut short スウォッチ交換ファイルが途中で切れています +@msg the {_1} plate can't be read: {e} {_1} 版を読み込めません: {e} +@msg this binary DXF file is damaged or cut short このバイナリ DXF ファイルは壊れているか途中で切れています +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that DWG 図面は読み込めません: CAD アプリで DXF として保存し、そのファイルを開いてください +@msg too many classes 文字クラスが多すぎます +@msg too many groups グループが多すぎます +@msg too many layers for PSD (at most 32767) PSD にはレイヤーが多すぎます (最大 32767) +@msg too many swatch exchange blocks スウォッチ交換ファイルのブロックが多すぎます +@msg unknown colour profile: {0} 不明なカラープロファイル: {0} +@msg {_1} clashes with a built-in profile name {_1} は組み込みのプロファイル名と重複しています +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} ピクセルは GIF には大きすぎます (1 辺最大 65535 ピクセル): 解像度を下げてください +@msg {_1}: no usable transform to/from sRGB {_1}: sRGB との間で使用できる変換がありません +@msg {name} is not a CMYK profile {name} は CMYK プロファイルではありません +@msg {name} is not an RGB profile {name} は RGB プロファイルではありません +@msg {path}: could not be read {path}: 読み込めませんでした +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} ピクセルは JPEG には大きすぎます (1 辺最大 65535 ピクセル): 解像度を下げてください +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} ピクセルは Targa には大きすぎます (1 辺最大 65535 ピクセル): 解像度を下げてください +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} ピクセルは PSD には大きすぎます (1 辺最大 {MAX_SIDE} ピクセル): 解像度を下げてください + +# Messages other catalogs translate and Japanese did not: Banner Hems and Grommets, a parameter range and the recovery save. +@msg `{key}` must be from 0.01 to 1000 pt `{key}` は 0.01~1000 pt にしてください +@msg select the banner, or mark every artboard バナーを選択するか、すべてのアートボードにマークを付けてください +@msg the banner has no width or height バナーに幅または高さがありません +@msg the grommet inset is too large for the banner 端からハトメまでの距離がバナーに対して大きすぎます +@msg the hem is too deep for the banner 折り返しがバナーに対して深すぎます +@msg the spacing makes more than {MAX_GROMMETS} grommets この間隔ではハトメが {MAX_GROMMETS} 個を超えます +@msg there is no banner to mark マークを付けるバナーがありません +@msg turn on at least one side 少なくとも 1 つの辺をオンにしてください +@msg with no hem and no grommets there is nothing to mark 折り返しもハトメもないため、マークを付けるものがありません +@msg {saving} failed: {e} {saving} に失敗しました: {e} diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index f2d20fc031..55f726dc38 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7586,6 +7586,12 @@ font-script Japanese იაპონური @msg open the practice sample of `{id}` first (see learn.openSample) ჯერ გახსენით `{id}` გაკვეთილის სავარჯიშო ნიმუში (იხ. learn.openSample) @msg finish `{id}` first (see learn.finish) ჯერ დაასრულეთ `{id}` (იხ. learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` თავისი უნარის ბოლო გაკვეთილია + Reset progress პროგრესის ჩამოყრა + Mark Tutorial Step გაკვეთილის ნაბიჯის მონიშვნა + Reset Learn Progress სწავლის პროგრესის ჩამოყრა +@msg missing `step` (see learn.show) აკლია `step` (იხ. learn.show) +@msg `step` must be a step number from 0 to {last} `step` უნდა იყოს ნაბიჯის ნომერი 0-დან {last}-მდე +@msg `{id}` is finished (see learn.resetProgress) `{id}` დასრულებულია (იხ. learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects გადადებული ეფექტების განახლება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8af8d3f53d..abe2b4bc28 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -7063,6 +7063,12 @@ breakText Glyphs 글리프 @msg open the practice sample of `{id}` first (see learn.openSample) 먼저 `{id}`의 연습용 샘플을 여세요(learn.openSample 참조) @msg finish `{id}` first (see learn.finish) 먼저 `{id}`을(를) 완료하세요(learn.finish 참조) @msg `{id}` is the last tutorial in its skill `{id}`은(는) 해당 기술의 마지막 튜토리얼입니다 + Reset progress 진행률 재설정 + Mark Tutorial Step 튜토리얼 단계 표시 + Reset Learn Progress 학습 진행률 재설정 +@msg missing `step` (see learn.show) `step`이 없습니다(learn.show 참조) +@msg `step` must be a step number from 0 to {last} `step`은 0에서 {last} 사이의 단계 번호여야 합니다 +@msg `{id}` is finished (see learn.resetProgress) `{id}`은(는) 완료되었습니다(learn.resetProgress 참조) # Deferred plug-in effects Refresh Deferred Effects 보류된 효과 새로 고침 @@ -7092,6 +7098,125 @@ breakText Glyphs 글리프 Grommets 아일렛 Grommet Inset 가장자리에서 아일렛까지 거리 Diameter 지름 + +# Messages of the 3D effects, image export, color profiles, swatch exchange files, color books, DXF import, +# path operations, libraries, scripts and zip files. +@msg 3D effects require finite coordinates within 100 million points 3D 효과에는 1억 포인트 이내의 유한한 좌표가 필요합니다 +@msg 3D effects require one to ten lights 3D 효과에는 광원이 1개에서 10개까지 필요합니다 +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP 비트 심도 {_1}: 픽셀당 1, 4, 8, 16, 24 또는 32비트여야 합니다 +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP 인코딩 실패: {width} × {height}픽셀이 형식이나 픽셀 버퍼에 맞지 않습니다 +@msg GIF encoding failed: the palette image is malformed GIF 인코딩 실패: 팔레트 이미지의 형식이 잘못되었습니다 +@msg GIF encoding failed: {e} GIF 인코딩 실패: {e} +@msg JPEG colour profile: {e} JPEG 색상 프로파일: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG 인코딩 실패: 픽셀 버퍼가 이미지 크기와 맞지 않습니다 +@msg JPEG encoding failed: {e} JPEG 인코딩 실패: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size PNG 인코딩 실패: 픽셀 버퍼가 이미지 크기와 맞지 않습니다 +@msg PNG encoding failed: the palette image is malformed PNG 인코딩 실패: 팔레트 이미지의 형식이 잘못되었습니다 +@msg PNG encoding failed: {e} PNG 인코딩 실패: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG 인코딩 실패: {width} × {height}픽셀은 올바른 이미지 크기가 아닙니다 +@msg PSD colour profile: {e} PSD 색상 프로파일: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD 인코딩 실패: 픽셀 버퍼가 이미지 크기와 맞지 않습니다 +@msg Revolve profile contains invalid coordinates 회전체 프로파일에 잘못된 좌표가 있습니다 +@msg Revolve profile is too complex 회전체 프로파일이 너무 복잡합니다 +@msg Revolve projection is not finite 회전체 투영이 유한하지 않습니다 +@msg Revolve requires finite profile coordinates within 100 million points 회전체에는 1억 포인트 이내의 유한한 프로파일 좌표가 필요합니다 +@msg Revolve surface is too complex; reduce Segments or simplify the profile 회전체 표면이 너무 복잡합니다. 세그먼트를 줄이거나 프로파일을 단순화하십시오 +@msg Revolve surface is too complex; simplify the profile 회전체 표면이 너무 복잡합니다. 프로파일을 단순화하십시오 +@msg Revolve visibility cannot split the projected boundary 회전체 가시성 계산에서 투영된 경계를 분할할 수 없습니다 +@msg Revolve visibility creates too many fragments 회전체 가시성 계산에서 조각이 너무 많이 생깁니다 +@msg Revolve visibility creates too many vector anchors 회전체 가시성 계산에서 벡터 고정점이 너무 많이 생깁니다 +@msg Revolve visibility exceeds its geometry work limit 회전체 가시성 계산이 기하 연산 한도를 넘습니다 +@msg Revolve visibility exceeds its spatial index limit 회전체 가시성 계산이 공간 인덱스 한도를 넘습니다 +@msg Revolve visibility has too many boundary crossings 회전체 가시성 계산에 경계 교차가 너무 많습니다 +@msg Revolve visibility has too many input anchors 회전체 가시성 계산의 입력 고정점이 너무 많습니다 +@msg Revolve visibility requires convex cap sectors 회전체 가시성 계산에는 볼록한 캡 부채꼴이 필요합니다 +@msg Revolve visibility requires finite projected faces 회전체 가시성 계산에는 유한한 투영 면이 필요합니다 +@msg SVG options: {e} SVG 옵션: {e} +@msg Save for Web settings: {e} 웹용 저장 설정: {e} +@msg TIFF colour profile: {e} TIFF 색상 프로파일: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF 인코딩 실패: 픽셀 버퍼가 이미지 크기와 맞지 않습니다 +@msg TIFF encoding failed: {e} TIFF 인코딩 실패: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa 비트 심도 {_1}: 픽셀당 16, 24 또는 32비트여야 합니다 +@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa 인코딩 실패: 픽셀 버퍼가 이미지 크기와 맞지 않습니다 +@msg The 3D projection is not finite 3D 투영이 유한하지 않습니다 +@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D 그림자가 너무 복잡합니다. 그림자를 끄거나 아트를 단순화하십시오 +@msg The shape contains invalid coordinates 모양에 잘못된 좌표가 있습니다 +@msg Too many Revolve faces for visibility evaluation 회전체의 면이 너무 많아 가시성을 계산할 수 없습니다 +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft는 견본 교환 파일에서 색상과 그룹을 최대 {ASE_MAX_ENTRIES}개까지 읽는데, 이 라이브러리에는 {entries}개가 있습니다 +@msg WebP encoding failed: {e} WebP 인코딩 실패: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `index` must be a whole number `index`은(는) 정수여야 합니다 +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view`는 smallThumb, mediumThumb, largeThumb, smallList 또는 largeList여야 합니다 +@msg a line can't run from a point to itself 선은 한 점에서 같은 점으로 이어질 수 없습니다 +@msg a line needs at least two points 선에는 점이 두 개 이상 필요합니다 +@msg a number cannot be read 숫자를 읽을 수 없습니다 +@msg a swatch exchange block is too long 견본 교환 블록이 너무 깁니다 +@msg a swatch exchange name is too long 견본 교환 이름이 너무 깁니다 +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels {sw} × {sh} 이미지를 {width} × {height}픽셀로 크기 조정할 수 없습니다 +@msg a {width} × {height} image is too large {width} × {height} 이미지가 너무 큽니다 +@msg a {width} × {height} image: {e} {width} × {height} 이미지: {e} +@msg an image could not be written: {e} 이미지를 기록할 수 없습니다: {e} +@msg an image that couldn't be read was left out 읽을 수 없는 이미지를 제외했습니다 +@msg can't open “{name}”: {e} “{name}”을(를) 열 수 없습니다: {e} +@msg cannot align zip entries to {align} bytes zip 항목을 {align}바이트 단위로 정렬할 수 없습니다 +@msg cannot open the automation {kind} root `{_1}`: {e} 자동화 {kind} 루트 `{_1}`을(를) 열 수 없습니다: {e} +@msg cannot read profile: {0} 프로파일을 읽을 수 없습니다: {0} +@msg color book version {version} isn't supported (only 1) 색상 견본집 버전 {version}은(는) 지원되지 않습니다(1만 지원) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) 색상 공간이 {other}인 색상 견본집은 지원되지 않습니다(RGB, CMYK, Lab만 지원) +@msg file name too long to zip: {_1} 파일 이름이 너무 길어 zip에 넣을 수 없습니다: {_1} +@msg file name too long to zip: {name} 파일 이름이 너무 길어 zip에 넣을 수 없습니다: {name} +@msg freeform line {i}: {e} 자유형 선 {i}: {e} +@msg freeform point {i}: {e} 자유형 점 {i}: {e} +@msg give `{key}` or `{other}`, not both `{key}`와 `{other}` 중 하나만 지정하세요 +@msg grayscale profiles can't be working spaces yet 그레이스케일 프로파일은 아직 작업 공간으로 사용할 수 없습니다 +@msg input contains NaN or infinite coordinates 입력에 NaN 또는 무한대 좌표가 있습니다 +@msg input path could not be closed 입력 패스를 닫을 수 없습니다 +@msg invalid ICC profile: {0} 잘못된 ICC 프로파일: {0} +@msg invalid scale {scale} 잘못된 비율 {scale} +@msg its localized names take more than {_1} KB 지역화된 이름이 {_1} KB를 넘습니다 +@msg its objects are nested too deep ({e}) 개체가 너무 깊이 중첩되어 있습니다({e}) +@msg its tag table lists the `{_1}` tag more than once 태그 표에 `{_1}` 태그가 두 번 이상 나열되어 있습니다 +@msg not a graphic style library: {e} 그래픽 스타일 라이브러리가 아닙니다: {e} +@msg not a swatch library (.vcswatches or .gpl) 견본 라이브러리가 아닙니다(.vcswatches 또는 .gpl) +@msg not a swatch library (format `{_1}`) 견본 라이브러리가 아닙니다(형식 `{_1}`) +@msg not a swatch library: {e} 견본 라이브러리가 아닙니다: {e} +@msg not an action set file ({e}) 액션 세트 파일이 아닙니다({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray 프로파일의 색상 공간 {other}은(는) RGB, CMYK 또는 Gray가 아닙니다 +@msg regular expression too large 정규식이 너무 큽니다 +@msg script file is not UTF-8 text: {value} 스크립트 파일이 UTF-8 텍스트가 아닙니다: {value} +@msg stop {i}: {e} 정지점 {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) 견본 교환 버전 {major}.{minor}은(는) 지원되지 않습니다(1.x만 지원) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors 색상 견본집의 색상이 {ASE_MAX_ENTRIES}개를 넘습니다 +@msg the color book is cut short 색상 견본집이 중간에 잘려 있습니다 +@msg the color book's names take more than {_1} MB 색상 견본집의 이름이 {_1} MB를 넘습니다 +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution 이미지가 {w} × {h}픽셀이 됩니다. 래스터 내보내기는 한 변에 {MAX_RASTER_SIDE}픽셀, {_1}메가픽셀로 제한됩니다. 비율이나 해상도를 낮추세요 +@msg the optimised image can't be read back: {e} 최적화된 이미지를 다시 읽을 수 없습니다: {e} +@msg the pixel buffer doesn't match the image size 픽셀 버퍼가 이미지 크기와 맞지 않습니다 +@msg the preview: {e} 미리 보기: {e} +@msg the request may not read the New Document profiles folder 이 요청은 새 문서 프로파일 폴더를 읽을 수 없습니다 +@msg the shapes are too degenerate to combine 모양이 너무 퇴화되어 결합할 수 없습니다 +@msg the shapes cross themselves and each other too often to combine 모양이 자기 자신 및 서로와 너무 자주 교차하여 결합할 수 없습니다 +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups 견본 교환 파일의 색상과 그룹이 {ASE_MAX_ENTRIES}개를 넘습니다 +@msg the swatch exchange file is cut short 견본 교환 파일이 중간에 잘려 있습니다 +@msg the {_1} plate can't be read: {e} {_1} 분판을 읽을 수 없습니다: {e} +@msg this binary DXF file is damaged or cut short 이 바이너리 DXF 파일이 손상되었거나 중간에 잘려 있습니다 +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that DWG 도면은 읽을 수 없습니다. CAD 앱에서 DXF로 저장한 후 여세요 +@msg too many classes 문자 클래스가 너무 많습니다 +@msg too many groups 그룹이 너무 많습니다 +@msg too many layers for PSD (at most 32767) PSD에 비해 레이어가 너무 많습니다(최대 32767개) +@msg too many swatch exchange blocks 견본 교환 블록이 너무 많습니다 +@msg unknown colour profile: {0} 알 수 없는 색상 프로파일: {0} +@msg {_1} clashes with a built-in profile name {_1}이(가) 기본 제공 프로파일 이름과 겹칩니다 +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2}픽셀은 GIF에 비해 너무 큽니다(한 변 최대 65535픽셀). 해상도를 낮추세요 +@msg {_1}: no usable transform to/from sRGB {_1}: sRGB와 주고받을 수 있는 변환이 없습니다 +@msg {name} is not a CMYK profile {name}은(는) CMYK 프로파일이 아닙니다 +@msg {name} is not an RGB profile {name}은(는) RGB 프로파일이 아닙니다 +@msg {path}: could not be read {path}: 읽을 수 없습니다 +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height}픽셀은 JPEG에 비해 너무 큽니다(한 변 최대 65535픽셀). 해상도를 낮추세요 +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height}픽셀은 Targa에 비해 너무 큽니다(한 변 최대 65535픽셀). 해상도를 낮추세요 +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h}픽셀은 PSD에 비해 너무 큽니다(한 변 최대 {MAX_SIDE}픽셀). 해상도를 낮추세요 + # Status and error messages of commands, scripts, file import and export, and plug-ins. @msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions 엄격 모드 함수에서는 'caller', 'callee', 'arguments'를 사용할 수 없습니다 @msg A profile supports one Revolve effect. Edit the existing effect's options instead. 프로파일 하나에는 회전체 효과를 하나만 쓸 수 있습니다. 기존 효과의 옵션을 편집하세요. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c8b777d01b..ed281382a4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -9323,6 +9323,12 @@ breakText Glyphs Glyphs @msg open the practice sample of `{id}` first (see learn.openSample) open eerst het oefenbestand van `{id}` (zie learn.openSample) @msg finish `{id}` first (see learn.finish) voltooi eerst `{id}` (zie learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` is de laatste tutorial van zijn vaardigheid + Reset progress Voortgang opnieuw instellen + Mark Tutorial Step Stap van tutorial markeren + Reset Learn Progress Leervoortgang opnieuw instellen +@msg missing `step` (see learn.show) `step` ontbreekt (zie learn.show) +@msg `step` must be a step number from 0 to {last} `step` moet een stapnummer van 0 tot en met {last} zijn +@msg `{id}` is finished (see learn.resetProgress) `{id}` is voltooid (zie learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Uitgestelde effecten vernieuwen @@ -9346,3 +9352,121 @@ breakText Glyphs Glyphs Grommets Zeilringen Grommet Inset Afstand van zeilringen tot de rand Diameter Diameter + +# Status and error messages: 3D effects and Revolve, image export, color profiles, swatch exchange files and +# color books, regular expressions in scripts, whole-number parameters, and DXF and DWG files. +@msg 3D effects require finite coordinates within 100 million points 3D-effecten vereisen eindige coördinaten binnen 100 miljoen punten +@msg 3D effects require one to ten lights 3D-effecten vereisen één tot tien lichten +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP-diepte {_1}: 1, 4, 8, 16, 24 of 32 bits per pixel +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP-codering mislukt: {width} × {height} pixels past niet in het formaat of in de pixelbuffer +@msg GIF encoding failed: the palette image is malformed GIF-codering mislukt: de paletafbeelding is ongeldig +@msg GIF encoding failed: {e} GIF-codering mislukt: {e} +@msg JPEG colour profile: {e} JPEG-kleurprofiel: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG-codering mislukt: de pixelbuffer komt niet overeen met de afbeeldingsgrootte +@msg JPEG encoding failed: {e} JPEG-codering mislukt: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size PNG-codering mislukt: de pixelbuffer komt niet overeen met de afbeeldingsgrootte +@msg PNG encoding failed: the palette image is malformed PNG-codering mislukt: de paletafbeelding is ongeldig +@msg PNG encoding failed: {e} PNG-codering mislukt: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG-codering mislukt: {width} × {height} pixels is geen geldige afbeeldingsgrootte +@msg PSD colour profile: {e} PSD-kleurprofiel: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD-codering mislukt: de pixelbuffer komt niet overeen met de afbeeldingsgrootte +@msg Revolve profile contains invalid coordinates Het profiel van Draaien bevat ongeldige coördinaten +@msg Revolve profile is too complex Het profiel van Draaien is te complex +@msg Revolve projection is not finite De projectie van Draaien is niet eindig +@msg Revolve requires finite profile coordinates within 100 million points Draaien vereist eindige profielcoördinaten binnen 100 miljoen punten +@msg Revolve surface is too complex; reduce Segments or simplify the profile Het oppervlak van Draaien is te complex; verlaag Segmenten of vereenvoudig het profiel +@msg Revolve surface is too complex; simplify the profile Het oppervlak van Draaien is te complex; vereenvoudig het profiel +@msg Revolve visibility cannot split the projected boundary De zichtbaarheid van Draaien kan de geprojecteerde rand niet splitsen +@msg Revolve visibility creates too many fragments De zichtbaarheid van Draaien maakt te veel fragmenten +@msg Revolve visibility creates too many vector anchors De zichtbaarheid van Draaien maakt te veel vectorankerpunten +@msg Revolve visibility exceeds its geometry work limit De zichtbaarheid van Draaien overschrijdt de werklimiet voor geometrie +@msg Revolve visibility exceeds its spatial index limit De zichtbaarheid van Draaien overschrijdt de limiet van de ruimtelijke index +@msg Revolve visibility has too many boundary crossings De zichtbaarheid van Draaien heeft te veel randkruisingen +@msg Revolve visibility has too many input anchors De zichtbaarheid van Draaien heeft te veel invoerankerpunten +@msg Revolve visibility requires convex cap sectors De zichtbaarheid van Draaien vereist convexe kapsectoren +@msg Revolve visibility requires finite projected faces De zichtbaarheid van Draaien vereist eindige geprojecteerde vlakken +@msg SVG options: {e} SVG-opties: {e} +@msg Save for Web settings: {e} Instellingen van Opslaan voor web: {e} +@msg TIFF colour profile: {e} TIFF-kleurprofiel: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF-codering mislukt: de pixelbuffer komt niet overeen met de afbeeldingsgrootte +@msg TIFF encoding failed: {e} TIFF-codering mislukt: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa-diepte {_1}: 16, 24 of 32 bits per pixel +@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa-codering mislukt: de pixelbuffer komt niet overeen met de afbeeldingsgrootte +@msg The 3D projection is not finite De 3D-projectie is niet eindig +@msg The 3D shadow is too complex; turn Shadows off or simplify the art De 3D-schaduw is te complex; zet Schaduwen uit of vereenvoudig de illustratie +@msg The shape contains invalid coordinates De vorm bevat ongeldige coördinaten +@msg Too many Revolve faces for visibility evaluation Te veel vlakken van Draaien om de zichtbaarheid te bepalen +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft leest maximaal {ASE_MAX_ENTRIES} kleuren en groepen uit een uitwisselingsbestand voor stalen, en deze bibliotheek heeft er {entries} +@msg WebP encoding failed: {e} WebP-codering mislukt: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `index` must be a whole number `index` moet een geheel getal zijn +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` moet smallThumb, mediumThumb, largeThumb, smallList of largeList zijn +@msg a line can't run from a point to itself een lijn kan niet van een punt naar zichzelf lopen +@msg a line needs at least two points een lijn heeft minstens twee punten nodig +@msg a number cannot be read een getal kan niet worden gelezen +@msg a swatch exchange block is too long een blok van een uitwisselingsbestand voor stalen is te lang +@msg a swatch exchange name is too long een naam in een uitwisselingsbestand voor stalen is te lang +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels een afbeelding van {sw} × {sh} kan niet worden geschaald naar {width} × {height} pixels +@msg a {width} × {height} image is too large een afbeelding van {width} × {height} is te groot +@msg a {width} × {height} image: {e} een afbeelding van {width} × {height}: {e} +@msg an image could not be written: {e} een afbeelding kon niet worden geschreven: {e} +@msg an image that couldn't be read was left out een afbeelding die niet gelezen kon worden, is weggelaten +@msg can't open “{name}”: {e} kan „{name}” niet openen: {e} +@msg cannot align zip entries to {align} bytes zip-items kunnen niet op {align} bytes worden uitgelijnd +@msg cannot open the automation {kind} root `{_1}`: {e} kan de hoofdmap voor automatisering ({kind}) `{_1}` niet openen: {e} +@msg cannot read profile: {0} kan het profiel niet lezen: {0} +@msg color book version {version} isn't supported (only 1) kleurenboekversie {version} wordt niet ondersteund (alleen 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) kleurenboeken in kleurruimte {other} worden niet ondersteund (alleen RGB, CMYK en Lab) +@msg file name too long to zip: {_1} bestandsnaam te lang om te zippen: {_1} +@msg file name too long to zip: {name} bestandsnaam te lang om te zippen: {name} +@msg freeform line {i}: {e} lijn in vrije vorm {i}: {e} +@msg freeform point {i}: {e} punt in vrije vorm {i}: {e} +@msg give `{key}` or `{other}`, not both geef `{key}` of `{other}`, niet beide +@msg grayscale profiles can't be working spaces yet grijswaardenprofielen kunnen nog geen werkruimte zijn +@msg input contains NaN or infinite coordinates de invoer bevat NaN of oneindige coördinaten +@msg input path could not be closed het invoerpad kon niet worden gesloten +@msg invalid ICC profile: {0} ongeldig ICC-profiel: {0} +@msg invalid scale {scale} ongeldige schaal {scale} +@msg its localized names take more than {_1} KB de gelokaliseerde namen ervan nemen meer dan {_1} KB in beslag +@msg its objects are nested too deep ({e}) de objecten ervan zijn te diep genest ({e}) +@msg its tag table lists the `{_1}` tag more than once de tagtabel ervan vermeldt de tag `{_1}` meer dan eens +@msg not a graphic style library: {e} geen bibliotheek met grafische stijlen: {e} +@msg not a swatch library (.vcswatches or .gpl) geen staalbibliotheek (.vcswatches of .gpl) +@msg not a swatch library (format `{_1}`) geen staalbibliotheek (indeling `{_1}`) +@msg not a swatch library: {e} geen staalbibliotheek: {e} +@msg not an action set file ({e}) geen actiesetbestand ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray profielkleurruimte {other} is geen RGB, CMYK of grijs +@msg regular expression too large reguliere expressie te groot +@msg script file is not UTF-8 text: {value} scriptbestand is geen UTF-8-tekst: {value} +@msg stop {i}: {e} stop {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) versie {major}.{minor} van uitwisselingsbestanden voor stalen wordt niet ondersteund (alleen 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors het kleurenboek bevat meer dan {ASE_MAX_ENTRIES} kleuren +@msg the color book is cut short het kleurenboek is afgebroken +@msg the color book's names take more than {_1} MB de namen in het kleurenboek nemen meer dan {_1} MB in beslag +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution de afbeelding zou {w} × {h} pixels worden; rasterexports zijn beperkt tot {MAX_RASTER_SIDE} pixels per zijde en {_1} megapixels: verlaag de schaal of de resolutie +@msg the optimised image can't be read back: {e} de geoptimaliseerde afbeelding kan niet worden teruggelezen: {e} +@msg the pixel buffer doesn't match the image size de pixelbuffer komt niet overeen met de afbeeldingsgrootte +@msg the preview: {e} de voorvertoning: {e} +@msg the request may not read the New Document profiles folder het verzoek mag de map met profielen voor nieuwe documenten niet lezen +@msg the shapes are too degenerate to combine de vormen zijn te gedegenereerd om te combineren +@msg the shapes cross themselves and each other too often to combine de vormen kruisen zichzelf en elkaar te vaak om te combineren +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups het uitwisselingsbestand voor stalen bevat meer dan {ASE_MAX_ENTRIES} kleuren en groepen +@msg the swatch exchange file is cut short het uitwisselingsbestand voor stalen is afgebroken +@msg the {_1} plate can't be read: {e} de drukplaat {_1} kan niet worden gelezen: {e} +@msg this binary DXF file is damaged or cut short dit binaire DXF-bestand is beschadigd of afgebroken +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that dit is een DWG-tekening, die niet gelezen kan worden: sla hem in de CAD-app op als DXF en open die +@msg too many classes te veel tekenklassen +@msg too many groups te veel groepen +@msg too many layers for PSD (at most 32767) te veel lagen voor PSD (maximaal 32767) +@msg too many swatch exchange blocks te veel blokken in het uitwisselingsbestand voor stalen +@msg unknown colour profile: {0} onbekend kleurprofiel: {0} +@msg {_1} clashes with a built-in profile name {_1} botst met een ingebouwde profielnaam +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} pixels is te groot voor GIF (maximaal 65535 pixels per zijde): verlaag de resolutie +@msg {_1}: no usable transform to/from sRGB {_1}: geen bruikbare transformatie van of naar sRGB +@msg {name} is not a CMYK profile {name} is geen CMYK-profiel +@msg {name} is not an RGB profile {name} is geen RGB-profiel +@msg {path}: could not be read {path}: kon niet worden gelezen +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pixels is te groot voor JPEG (maximaal 65535 pixels per zijde): verlaag de resolutie +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pixels is te groot voor Targa (maximaal 65535 pixels per zijde): verlaag de resolutie +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pixels is te groot voor PSD (maximaal {MAX_SIDE} pixels per zijde): verlaag de resolutie diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1c49a7327a..08e96e6a35 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -7329,6 +7329,12 @@ breakText Glyphs Glify @msg open the practice sample of `{id}` first (see learn.openSample) najpierw otwórz przykład do ćwiczeń samouczka `{id}` (patrz learn.openSample) @msg finish `{id}` first (see learn.finish) najpierw zakończ `{id}` (patrz learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` to ostatni samouczek swojej umiejętności + Reset progress Resetuj postępy + Mark Tutorial Step Oznacz krok samouczka + Reset Learn Progress Resetuj postępy nauki +@msg missing `step` (see learn.show) brak `step` (patrz learn.show) +@msg `step` must be a step number from 0 to {last} `step` musi być numerem kroku od 0 do {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` jest zakończony (patrz learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Odśwież odroczone efekty @@ -9168,3 +9174,119 @@ breakText Glyphs Glify @msg {what} can't be intertwined {what} nie można przeplatać @msg {what} doesn't fit the paper with its bleed and marks {what} nie mieści się na papierze wraz ze spadem i znacznikami @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what}: wydruk zająłby ponad {MAX_TILES} kafelków: zmniejsz skalę + +# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. +@msg 3D effects require finite coordinates within 100 million points Efekty 3D wymagają skończonych współrzędnych w zakresie 100 milionów punktów +@msg 3D effects require one to ten lights Efekty 3D wymagają od jednego do dziesięciu świateł +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Głębia BMP {_1}: 1, 4, 8, 16, 24 lub 32 bity na piksel +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Kodowanie BMP nie powiodło się: {width} × {height} pikseli nie mieści się w formacie ani w buforze pikseli +@msg GIF encoding failed: the palette image is malformed Kodowanie GIF nie powiodło się: obraz z paletą jest nieprawidłowy +@msg GIF encoding failed: {e} Kodowanie GIF nie powiodło się: {e} +@msg JPEG colour profile: {e} Profil kolorów JPEG: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size Kodowanie JPEG nie powiodło się: bufor pikseli nie odpowiada rozmiarowi obrazu +@msg JPEG encoding failed: {e} Kodowanie JPEG nie powiodło się: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size Kodowanie PNG nie powiodło się: bufor pikseli nie odpowiada rozmiarowi obrazu +@msg PNG encoding failed: the palette image is malformed Kodowanie PNG nie powiodło się: obraz z paletą jest nieprawidłowy +@msg PNG encoding failed: {e} Kodowanie PNG nie powiodło się: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Kodowanie PNG nie powiodło się: {width} × {height} pikseli to nieprawidłowy rozmiar obrazu +@msg PSD colour profile: {e} Profil kolorów PSD: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size Kodowanie PSD nie powiodło się: bufor pikseli nie odpowiada rozmiarowi obrazu +@msg Revolve profile contains invalid coordinates Profil efektu Obrót zawiera nieprawidłowe współrzędne +@msg Revolve profile is too complex Profil efektu Obrót jest zbyt złożony +@msg Revolve projection is not finite Rzut efektu Obrót nie jest skończony +@msg Revolve requires finite profile coordinates within 100 million points Efekt Obrót wymaga skończonych współrzędnych profilu w zakresie 100 milionów punktów +@msg Revolve surface is too complex; reduce Segments or simplify the profile Powierzchnia efektu Obrót jest zbyt złożona; zmniejsz Segmenty lub uprość profil +@msg Revolve surface is too complex; simplify the profile Powierzchnia efektu Obrót jest zbyt złożona; uprość profil +@msg Revolve visibility cannot split the projected boundary Obliczanie widoczności efektu Obrót nie może podzielić rzutowanej granicy +@msg Revolve visibility creates too many fragments Obliczanie widoczności efektu Obrót tworzy zbyt wiele fragmentów +@msg Revolve visibility creates too many vector anchors Obliczanie widoczności efektu Obrót tworzy zbyt wiele wektorowych punktów kontrolnych +@msg Revolve visibility exceeds its geometry work limit Obliczanie widoczności efektu Obrót przekracza swój limit obliczeń geometrycznych +@msg Revolve visibility exceeds its spatial index limit Obliczanie widoczności efektu Obrót przekracza swój limit indeksu przestrzennego +@msg Revolve visibility has too many boundary crossings Obliczanie widoczności efektu Obrót napotyka zbyt wiele przecięć granicy +@msg Revolve visibility has too many input anchors Obliczanie widoczności efektu Obrót otrzymuje zbyt wiele wejściowych punktów kontrolnych +@msg Revolve visibility requires convex cap sectors Obliczanie widoczności efektu Obrót wymaga wypukłych sektorów zakończenia +@msg Revolve visibility requires finite projected faces Obliczanie widoczności efektu Obrót wymaga skończonych rzutowanych ścian +@msg SVG options: {e} Opcje SVG: {e} +@msg Save for Web settings: {e} Ustawienia Zapisz dla Internetu: {e} +@msg TIFF colour profile: {e} Profil kolorów TIFF: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size Kodowanie TIFF nie powiodło się: bufor pikseli nie odpowiada rozmiarowi obrazu +@msg TIFF encoding failed: {e} Kodowanie TIFF nie powiodło się: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Głębia Targa {_1}: 16, 24 lub 32 bity na piksel +@msg Targa encoding failed: the pixel buffer doesn't match the image size Kodowanie Targa nie powiodło się: bufor pikseli nie odpowiada rozmiarowi obrazu +@msg The 3D projection is not finite Rzut 3D nie jest skończony +@msg The 3D shadow is too complex; turn Shadows off or simplify the art Cień 3D jest zbyt złożony; wyłącz Cienie lub uprość grafikę +@msg The shape contains invalid coordinates Kształt zawiera nieprawidłowe współrzędne +@msg Too many Revolve faces for visibility evaluation Zbyt wiele ścian efektu Obrót do obliczenia widoczności +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft odczytuje z pliku wymiany próbek najwyżej {ASE_MAX_ENTRIES} kolorów i grup, a ta biblioteka ma ich {entries} +@msg WebP encoding failed: {e} Kodowanie WebP nie powiodło się: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` ma wartość smallThumb, mediumThumb, largeThumb, smallList lub largeList +@msg a line can't run from a point to itself linia nie może prowadzić od punktu do niego samego +@msg a line needs at least two points linia wymaga co najmniej dwóch punktów +@msg a number cannot be read nie można odczytać liczby +@msg a swatch exchange block is too long blok pliku wymiany próbek jest zbyt długi +@msg a swatch exchange name is too long nazwa w pliku wymiany próbek jest zbyt długa +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels nie można zmienić rozmiaru obrazu {sw} × {sh} na {width} × {height} pikseli +@msg a {width} × {height} image is too large obraz {width} × {height} jest zbyt duży +@msg a {width} × {height} image: {e} obraz {width} × {height}: {e} +@msg an image could not be written: {e} nie udało się zapisać obrazu: {e} +@msg an image that couldn't be read was left out pominięto obraz, którego nie udało się odczytać +@msg can't open “{name}”: {e} nie można otworzyć „{name}”: {e} +@msg cannot align zip entries to {align} bytes nie można wyrównać wpisów zip do {align} bajtów +@msg cannot open the automation {kind} root `{_1}`: {e} nie można otworzyć katalogu głównego automatyzacji ({kind}) `{_1}`: {e} +@msg cannot read profile: {0} nie można odczytać profilu: {0} +@msg color book version {version} isn't supported (only 1) wersja księgi kolorów {version} nie jest obsługiwana (tylko 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) księgi kolorów w przestrzeni kolorów {other} nie są obsługiwane (tylko RGB, CMYK i Lab) +@msg file name too long to zip: {_1} nazwa pliku jest zbyt długa dla archiwum zip: {_1} +@msg file name too long to zip: {name} nazwa pliku jest zbyt długa dla archiwum zip: {name} +@msg freeform line {i}: {e} dowolna linia {i}: {e} +@msg freeform point {i}: {e} dowolny punkt {i}: {e} +@msg give `{key}` or `{other}`, not both podaj `{key}` lub `{other}`, nie oba naraz +@msg grayscale profiles can't be working spaces yet profile w skali szarości nie mogą jeszcze być przestrzeniami roboczymi +@msg input contains NaN or infinite coordinates dane wejściowe zawierają współrzędne NaN lub nieskończone +@msg input path could not be closed nie udało się zamknąć ścieżki wejściowej +@msg invalid ICC profile: {0} nieprawidłowy profil ICC: {0} +@msg invalid scale {scale} nieprawidłowa skala {scale} +@msg its localized names take more than {_1} KB jego zlokalizowane nazwy zajmują ponad {_1} KB +@msg its objects are nested too deep ({e}) jego obiekty są zagnieżdżone zbyt głęboko ({e}) +@msg its tag table lists the `{_1}` tag more than once jego tablica znaczników wymienia znacznik `{_1}` więcej niż raz +@msg not a graphic style library: {e} to nie jest biblioteka stylów graficznych: {e} +@msg not a swatch library (.vcswatches or .gpl) to nie jest biblioteka próbek (.vcswatches lub .gpl) +@msg not a swatch library (format `{_1}`) to nie jest biblioteka próbek (format `{_1}`) +@msg not a swatch library: {e} to nie jest biblioteka próbek: {e} +@msg not an action set file ({e}) to nie jest plik zestawu operacji ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray przestrzeń kolorów profilu {other} nie jest RGB, CMYK ani skalą szarości +@msg regular expression too large wyrażenie regularne jest zbyt duże +@msg script file is not UTF-8 text: {value} plik skryptu nie jest tekstem UTF-8: {value} +@msg stop {i}: {e} punkt koloru {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) wersja pliku wymiany próbek {major}.{minor} nie jest obsługiwana (tylko 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors księga kolorów zawiera ponad {ASE_MAX_ENTRIES} kolorów +@msg the color book is cut short księga kolorów jest ucięta +@msg the color book's names take more than {_1} MB nazwy w księdze kolorów zajmują ponad {_1} MB +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution obraz miałby {w} × {h} pikseli; eksport rastrowy jest ograniczony do {MAX_RASTER_SIDE} pikseli na bok i {_1} megapikseli: zmniejsz skalę lub rozdzielczość +@msg the optimised image can't be read back: {e} nie można ponownie odczytać zoptymalizowanego obrazu: {e} +@msg the pixel buffer doesn't match the image size bufor pikseli nie odpowiada rozmiarowi obrazu +@msg the preview: {e} podgląd: {e} +@msg the request may not read the New Document profiles folder żądanie nie ma uprawnień do odczytu folderu profili nowego dokumentu +@msg the shapes are too degenerate to combine kształty są zbyt zdegenerowane, aby je połączyć +@msg the shapes cross themselves and each other too often to combine kształty zbyt często przecinają same siebie i siebie nawzajem, aby je połączyć +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups plik wymiany próbek zawiera ponad {ASE_MAX_ENTRIES} kolorów i grup +@msg the swatch exchange file is cut short plik wymiany próbek jest ucięty +@msg the {_1} plate can't be read: {e} nie można odczytać płyty {_1}: {e} +@msg this binary DXF file is damaged or cut short ten binarny plik DXF jest uszkodzony lub ucięty +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that to rysunek DWG, którego nie można odczytać: zapisz go w programie CAD jako DXF i otwórz ten plik +@msg too many classes zbyt wiele klas +@msg too many groups zbyt wiele grup +@msg too many layers for PSD (at most 32767) zbyt wiele warstw dla formatu PSD (najwyżej 32767) +@msg too many swatch exchange blocks zbyt wiele bloków pliku wymiany próbek +@msg unknown colour profile: {0} nieznany profil kolorów: {0} +@msg {_1} clashes with a built-in profile name {_1} koliduje z nazwą wbudowanego profilu +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} pikseli to za dużo dla formatu GIF (najwyżej 65535 pikseli na bok): zmniejsz rozdzielczość +@msg {_1}: no usable transform to/from sRGB {_1}: brak użytecznej transformacji do lub z sRGB +@msg {name} is not a CMYK profile {name} nie jest profilem CMYK +@msg {name} is not an RGB profile {name} nie jest profilem RGB +@msg {path}: could not be read {path}: nie udało się odczytać +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pikseli to za dużo dla formatu JPEG (najwyżej 65535 pikseli na bok): zmniejsz rozdzielczość +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pikseli to za dużo dla formatu Targa (najwyżej 65535 pikseli na bok): zmniejsz rozdzielczość +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pikseli to za dużo dla formatu PSD (najwyżej {MAX_SIDE} pikseli na bok): zmniejsz rozdzielczość diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 12647b2884..79c4d9dc8e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -7051,6 +7051,122 @@ breakText Glyphs Glifos @msg save the document with Save As before Save and Close salve o documento com Salvar Como antes de Salvar e fechar @msg no free data set file name in destination folder Não há nome de arquivo livre para o conjunto de dados na pasta de destino +# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. +@msg 3D effects require finite coordinates within 100 million points Os efeitos 3D exigem coordenadas finitas de até 100 milhões de pontos +@msg 3D effects require one to ten lights Os efeitos 3D exigem de uma a dez luzes +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Profundidade BMP {_1}: 1, 4, 8, 16, 24 ou 32 bits por pixel +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Falha na codificação BMP: {width} × {height} pixels não cabem no formato ou no buffer de pixels +@msg GIF encoding failed: the palette image is malformed Falha na codificação GIF: a imagem de paleta está malformada +@msg GIF encoding failed: {e} Falha na codificação GIF: {e} +@msg JPEG colour profile: {e} Perfil de cor JPEG: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size Falha na codificação JPEG: o buffer de pixels não corresponde ao tamanho da imagem +@msg JPEG encoding failed: {e} Falha na codificação JPEG: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size Falha na codificação PNG: o buffer de pixels não corresponde ao tamanho da imagem +@msg PNG encoding failed: the palette image is malformed Falha na codificação PNG: a imagem de paleta está malformada +@msg PNG encoding failed: {e} Falha na codificação PNG: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Falha na codificação PNG: {width} × {height} pixels não é um tamanho de imagem válido +@msg PSD colour profile: {e} Perfil de cor PSD: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size Falha na codificação PSD: o buffer de pixels não corresponde ao tamanho da imagem +@msg Revolve profile contains invalid coordinates O perfil de Revolver contém coordenadas inválidas +@msg Revolve profile is too complex O perfil de Revolver é complexo demais +@msg Revolve projection is not finite A projeção de Revolver não é finita +@msg Revolve requires finite profile coordinates within 100 million points Revolver exige coordenadas de perfil finitas de até 100 milhões de pontos +@msg Revolve surface is too complex; reduce Segments or simplify the profile A superfície de Revolver é complexa demais; reduza Segmentos ou simplifique o perfil +@msg Revolve surface is too complex; simplify the profile A superfície de Revolver é complexa demais; simplifique o perfil +@msg Revolve visibility cannot split the projected boundary A visibilidade de Revolver não consegue dividir o contorno projetado +@msg Revolve visibility creates too many fragments A visibilidade de Revolver cria fragmentos demais +@msg Revolve visibility creates too many vector anchors A visibilidade de Revolver cria pontos de ancoragem vetoriais demais +@msg Revolve visibility exceeds its geometry work limit A visibilidade de Revolver excede seu limite de trabalho geométrico +@msg Revolve visibility exceeds its spatial index limit A visibilidade de Revolver excede seu limite de índice espacial +@msg Revolve visibility has too many boundary crossings A visibilidade de Revolver tem cruzamentos de contorno demais +@msg Revolve visibility has too many input anchors A visibilidade de Revolver tem pontos de ancoragem de entrada demais +@msg Revolve visibility requires convex cap sectors A visibilidade de Revolver exige setores de tampa convexos +@msg Revolve visibility requires finite projected faces A visibilidade de Revolver exige faces projetadas finitas +@msg SVG options: {e} Opções SVG: {e} +@msg Save for Web settings: {e} Configurações de Salvar para a Web: {e} +@msg TIFF colour profile: {e} Perfil de cor TIFF: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size Falha na codificação TIFF: o buffer de pixels não corresponde ao tamanho da imagem +@msg TIFF encoding failed: {e} Falha na codificação TIFF: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Profundidade Targa {_1}: 16, 24 ou 32 bits por pixel +@msg Targa encoding failed: the pixel buffer doesn't match the image size Falha na codificação Targa: o buffer de pixels não corresponde ao tamanho da imagem +@msg The 3D projection is not finite A projeção 3D não é finita +@msg The 3D shadow is too complex; turn Shadows off or simplify the art A sombra 3D é complexa demais; desative Sombras ou simplifique a arte +@msg The shape contains invalid coordinates A forma contém coordenadas inválidas +@msg Too many Revolve faces for visibility evaluation Faces de Revolver demais para avaliar a visibilidade +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} O VectorCraft lê no máximo {ASE_MAX_ENTRIES} cores e grupos de um arquivo de intercâmbio de amostras, e esta biblioteca tem {entries} +@msg WebP encoding failed: {e} Falha na codificação WebP: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` é smallThumb, mediumThumb, largeThumb, smallList ou largeList +@msg a line can't run from a point to itself uma linha não pode ir de um ponto até ele mesmo +@msg a line needs at least two points uma linha precisa de pelo menos dois pontos +@msg a number cannot be read um número não pode ser lido +@msg a swatch exchange block is too long um bloco de intercâmbio de amostras é longo demais +@msg a swatch exchange name is too long um nome de intercâmbio de amostras é longo demais +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels uma imagem de {sw} × {sh} não pode ser redimensionada para {width} × {height} pixels +@msg a {width} × {height} image is too large uma imagem de {width} × {height} é grande demais +@msg a {width} × {height} image: {e} uma imagem de {width} × {height}: {e} +@msg an image could not be written: {e} não foi possível gravar uma imagem: {e} +@msg an image that couldn't be read was left out uma imagem que não pôde ser lida foi omitida +@msg can't open “{name}”: {e} não é possível abrir “{name}”: {e} +@msg cannot align zip entries to {align} bytes não é possível alinhar as entradas do zip a {align} bytes +@msg cannot open the automation {kind} root `{_1}`: {e} não é possível abrir a raiz de {kind} da automação `{_1}`: {e} +@msg cannot read profile: {0} não é possível ler o perfil: {0} +@msg color book version {version} isn't supported (only 1) a versão {version} do livro de cores não tem suporte (somente 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) livros de cores no espaço de cores {other} não têm suporte (somente RGB, CMYK e Lab) +@msg file name too long to zip: {_1} nome de arquivo longo demais para o zip: {_1} +@msg file name too long to zip: {name} nome de arquivo longo demais para o zip: {name} +@msg freeform line {i}: {e} linha {i} de forma livre: {e} +@msg freeform point {i}: {e} ponto {i} de forma livre: {e} +@msg give `{key}` or `{other}`, not both informe `{key}` ou `{other}`, não ambos +@msg grayscale profiles can't be working spaces yet perfis em escala de cinza ainda não podem ser espaços de trabalho de cores +@msg input contains NaN or infinite coordinates a entrada contém coordenadas NaN ou infinitas +@msg input path could not be closed não foi possível fechar o caminho de entrada +@msg invalid ICC profile: {0} perfil ICC inválido: {0} +@msg invalid scale {scale} escala inválida {scale} +@msg its localized names take more than {_1} KB seus nomes localizados ocupam mais de {_1} KB +@msg its objects are nested too deep ({e}) seus objetos estão aninhados profundamente demais ({e}) +@msg its tag table lists the `{_1}` tag more than once sua tabela de tags lista a tag `{_1}` mais de uma vez +@msg not a graphic style library: {e} não é uma biblioteca de estilos gráficos: {e} +@msg not a swatch library (.vcswatches or .gpl) não é uma biblioteca de amostras (.vcswatches ou .gpl) +@msg not a swatch library (format `{_1}`) não é uma biblioteca de amostras (formato `{_1}`) +@msg not a swatch library: {e} não é uma biblioteca de amostras: {e} +@msg not an action set file ({e}) não é um arquivo de conjunto de ações ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray o espaço de cores do perfil, {other}, não é RGB, CMYK ou Cinza +@msg regular expression too large expressão regular grande demais +@msg script file is not UTF-8 text: {value} o arquivo de script não é texto UTF-8: {value} +@msg stop {i}: {e} parada {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) a versão {major}.{minor} de intercâmbio de amostras não tem suporte (somente 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors o livro de cores contém mais de {ASE_MAX_ENTRIES} cores +@msg the color book is cut short o livro de cores está incompleto +@msg the color book's names take more than {_1} MB os nomes do livro de cores ocupam mais de {_1} MB +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution a imagem teria {w} × {h} pixels; as exportações raster são limitadas a {MAX_RASTER_SIDE} pixels por lado e {_1} megapixels: reduza a escala ou a resolução +@msg the optimised image can't be read back: {e} a imagem otimizada não pode ser lida de volta: {e} +@msg the pixel buffer doesn't match the image size o buffer de pixels não corresponde ao tamanho da imagem +@msg the preview: {e} a visualização: {e} +@msg the request may not read the New Document profiles folder a solicitação não tem permissão para ler a pasta de perfis de Novo Documento +@msg the shapes are too degenerate to combine as formas são degeneradas demais para combinar +@msg the shapes cross themselves and each other too often to combine as formas se cruzam consigo mesmas e entre si vezes demais para combinar +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups o arquivo de intercâmbio de amostras contém mais de {ASE_MAX_ENTRIES} cores e grupos +@msg the swatch exchange file is cut short o arquivo de intercâmbio de amostras está incompleto +@msg the {_1} plate can't be read: {e} a chapa {_1} não pode ser lida: {e} +@msg this binary DXF file is damaged or cut short este arquivo DXF binário está danificado ou incompleto +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that este é um desenho DWG, que não pode ser lido: salve-o no aplicativo de CAD como DXF e abra esse arquivo +@msg too many classes classes demais +@msg too many groups grupos demais +@msg too many layers for PSD (at most 32767) camadas demais para PSD (no máximo 32767) +@msg too many swatch exchange blocks blocos de intercâmbio de amostras demais +@msg unknown colour profile: {0} perfil de cor desconhecido: {0} +@msg {_1} clashes with a built-in profile name {_1} coincide com o nome de um perfil integrado +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} pixels é grande demais para GIF (no máximo 65535 pixels por lado): reduza a resolução +@msg {_1}: no usable transform to/from sRGB {_1}: nenhuma transformação utilizável de/para sRGB +@msg {name} is not a CMYK profile {name} não é um perfil CMYK +@msg {name} is not an RGB profile {name} não é um perfil RGB +@msg {path}: could not be read {path}: não foi possível ler +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pixels é grande demais para JPEG (no máximo 65535 pixels por lado): reduza a resolução +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pixels é grande demais para Targa (no máximo 65535 pixels por lado): reduza a resolução +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pixels é grande demais para PSD (no máximo {MAX_SIDE} pixels por lado): reduza a resolução + # Path Precision Tools add-on: Path Effects (Effect › Path Effects). Distance Distância Mode Modo @@ -7545,6 +7661,12 @@ swatch Yellow Green Verde-amarelado @msg open the practice sample of `{id}` first (see learn.openSample) abra primeiro a amostra de prática de `{id}` (veja learn.openSample) @msg finish `{id}` first (see learn.finish) conclua primeiro `{id}` (veja learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` é o último tutorial da sua habilidade + Reset progress Redefinir progresso + Mark Tutorial Step Marcar Etapa do Tutorial + Reset Learn Progress Redefinir Progresso de Aprendizagem +@msg missing `step` (see learn.show) falta `step` (veja learn.show) +@msg `step` must be a step number from 0 to {last} `step` deve ser um número de etapa de 0 a {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` está concluído (veja learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Atualizar Efeitos Adiados diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b3ec6a7684..9f608618e7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7561,6 +7561,12 @@ shape Open Открытый @msg open the practice sample of `{id}` first (see learn.openSample) сначала откройте учебный пример урока `{id}` (см. learn.openSample) @msg finish `{id}` first (see learn.finish) сначала завершите `{id}` (см. learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` является последним уроком своего навыка + Reset progress Сбросить прогресс + Mark Tutorial Step Отметить шаг урока + Reset Learn Progress Сбросить прогресс обучения +@msg missing `step` (see learn.show) отсутствует `step` (см. learn.show) +@msg `step` must be a step number from 0 to {last} `step` должен быть номером шага от 0 до {last} +@msg `{id}` is finished (see learn.resetProgress) урок `{id}` завершён (см. learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Обновить отложенные эффекты diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e16a0cb6e1..6156ca23d5 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8910,6 +8910,12 @@ digits Hindi Hindi @msg open the practice sample of `{id}` first (see learn.openSample) öppna först övningsexemplet för `{id}` (se learn.openSample) @msg finish `{id}` first (see learn.finish) slutför först `{id}` (se learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` är den sista självstudien i sin färdighet + Reset progress Återställ framsteg + Mark Tutorial Step Markera steg i självstudie + Reset Learn Progress Återställ inlärningsframsteg +@msg missing `step` (see learn.show) `step` saknas (se learn.show) +@msg `step` must be a step number from 0 to {last} `step` måste vara ett stegnummer från 0 till {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` är slutförd (se learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Uppdatera uppskjutna effekter @@ -8976,3 +8982,119 @@ digits Hindi Hindi Grommets Öljetter Grommet Inset Öljetternas avstånd från kanten Diameter Diameter + +# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. +@msg 3D effects require finite coordinates within 100 million points 3D-effekter kräver ändliga koordinater inom 100 miljoner punkter +@msg 3D effects require one to ten lights 3D-effekter kräver en till tio ljuskällor +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP-djup {_1}: 1, 4, 8, 16, 24 eller 32 bitar per pixel +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP-kodningen misslyckades: {width} × {height} pixlar passar inte formatet eller pixelbufferten +@msg GIF encoding failed: the palette image is malformed GIF-kodningen misslyckades: palettbilden är felaktigt uppbyggd +@msg GIF encoding failed: {e} GIF-kodningen misslyckades: {e} +@msg JPEG colour profile: {e} JPEG-färgprofil: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG-kodningen misslyckades: pixelbufferten stämmer inte med bildens storlek +@msg JPEG encoding failed: {e} JPEG-kodningen misslyckades: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size PNG-kodningen misslyckades: pixelbufferten stämmer inte med bildens storlek +@msg PNG encoding failed: the palette image is malformed PNG-kodningen misslyckades: palettbilden är felaktigt uppbyggd +@msg PNG encoding failed: {e} PNG-kodningen misslyckades: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG-kodningen misslyckades: {width} × {height} pixlar är inte en giltig bildstorlek +@msg PSD colour profile: {e} PSD-färgprofil: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD-kodningen misslyckades: pixelbufferten stämmer inte med bildens storlek +@msg Revolve profile contains invalid coordinates Profilen för Rotera runt axel innehåller ogiltiga koordinater +@msg Revolve profile is too complex Profilen för Rotera runt axel är för komplex +@msg Revolve projection is not finite Projektionen för Rotera runt axel är inte ändlig +@msg Revolve requires finite profile coordinates within 100 million points Rotera runt axel kräver ändliga profilkoordinater inom 100 miljoner punkter +@msg Revolve surface is too complex; reduce Segments or simplify the profile Ytan för Rotera runt axel är för komplex; minska Segment eller förenkla profilen +@msg Revolve surface is too complex; simplify the profile Ytan för Rotera runt axel är för komplex; förenkla profilen +@msg Revolve visibility cannot split the projected boundary Synlighetsberäkningen för Rotera runt axel kan inte dela den projicerade gränsen +@msg Revolve visibility creates too many fragments Synlighetsberäkningen för Rotera runt axel skapar för många fragment +@msg Revolve visibility creates too many vector anchors Synlighetsberäkningen för Rotera runt axel skapar för många vektorankarpunkter +@msg Revolve visibility exceeds its geometry work limit Synlighetsberäkningen för Rotera runt axel överskrider sin gräns för geometriarbete +@msg Revolve visibility exceeds its spatial index limit Synlighetsberäkningen för Rotera runt axel överskrider sin gräns för det rumsliga indexet +@msg Revolve visibility has too many boundary crossings Synlighetsberäkningen för Rotera runt axel har för många gränskorsningar +@msg Revolve visibility has too many input anchors Synlighetsberäkningen för Rotera runt axel har för många inkommande ankarpunkter +@msg Revolve visibility requires convex cap sectors Synlighetsberäkningen för Rotera runt axel kräver konvexa sektorer i locken +@msg Revolve visibility requires finite projected faces Synlighetsberäkningen för Rotera runt axel kräver ändliga projicerade ytor +@msg SVG options: {e} SVG-alternativ: {e} +@msg Save for Web settings: {e} Inställningar för Spara för webben: {e} +@msg TIFF colour profile: {e} TIFF-färgprofil: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF-kodningen misslyckades: pixelbufferten stämmer inte med bildens storlek +@msg TIFF encoding failed: {e} TIFF-kodningen misslyckades: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa-djup {_1}: 16, 24 eller 32 bitar per pixel +@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa-kodningen misslyckades: pixelbufferten stämmer inte med bildens storlek +@msg The 3D projection is not finite 3D-projektionen är inte ändlig +@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D-skuggan är för komplex; stäng av Skuggor eller förenkla grafiken +@msg The shape contains invalid coordinates Formen innehåller ogiltiga koordinater +@msg Too many Revolve faces for visibility evaluation För många ytor i Rotera runt axel för synlighetsberäkningen +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft läser högst {ASE_MAX_ENTRIES} färger och grupper från en fil för färgruteutbyte, och det här biblioteket har {entries} +@msg WebP encoding failed: {e} WebP-kodningen misslyckades: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `index` must be a whole number `index` måste vara ett heltal +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` är smallThumb, mediumThumb, largeThumb, smallList eller largeList +@msg a line can't run from a point to itself en linje kan inte gå från en punkt till sig själv +@msg a line needs at least two points en linje behöver minst två punkter +@msg a number cannot be read ett tal kan inte läsas +@msg a swatch exchange block is too long ett block i en fil för färgruteutbyte är för långt +@msg a swatch exchange name is too long ett namn i en fil för färgruteutbyte är för långt +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels en bild på {sw} × {sh} kan inte skalas om till {width} × {height} pixlar +@msg a {width} × {height} image is too large en bild på {width} × {height} är för stor +@msg a {width} × {height} image: {e} en bild på {width} × {height}: {e} +@msg an image could not be written: {e} en bild kunde inte skrivas: {e} +@msg an image that couldn't be read was left out en bild som inte kunde läsas utelämnades +@msg can't open “{name}”: {e} kan inte öppna ”{name}”: {e} +@msg cannot align zip entries to {align} bytes kan inte justera zip-posterna till {align} byte +@msg cannot open the automation {kind} root `{_1}`: {e} kan inte öppna automatiseringens rotmapp ({kind}) `{_1}`: {e} +@msg cannot read profile: {0} kan inte läsa profilen: {0} +@msg color book version {version} isn't supported (only 1) färgboksversion {version} stöds inte (endast 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) färgböcker i färgrymden {other} stöds inte (endast RGB, CMYK och Lab) +@msg file name too long to zip: {_1} filnamnet är för långt för att zippas: {_1} +@msg file name too long to zip: {name} filnamnet är för långt för att zippas: {name} +@msg freeform line {i}: {e} fri linje {i}: {e} +@msg freeform point {i}: {e} fri punkt {i}: {e} +@msg give `{key}` or `{other}`, not both ange `{key}` eller `{other}`, inte båda +@msg grayscale profiles can't be working spaces yet gråskaleprofiler kan inte vara arbetsfärgrymder än +@msg input contains NaN or infinite coordinates indata innehåller NaN eller oändliga koordinater +@msg input path could not be closed den inkommande banan kunde inte slutas +@msg invalid ICC profile: {0} ogiltig ICC-profil: {0} +@msg invalid scale {scale} ogiltig skala {scale} +@msg its localized names take more than {_1} KB dess lokaliserade namn tar mer än {_1} KB +@msg its objects are nested too deep ({e}) dess objekt är kapslade för djupt ({e}) +@msg its tag table lists the `{_1}` tag more than once dess taggtabell listar taggen `{_1}` mer än en gång +@msg not a graphic style library: {e} inte ett bibliotek med grafiska stilar: {e} +@msg not a swatch library (.vcswatches or .gpl) inte ett färgrutebibliotek (.vcswatches eller .gpl) +@msg not a swatch library (format `{_1}`) inte ett färgrutebibliotek (format `{_1}`) +@msg not a swatch library: {e} inte ett färgrutebibliotek: {e} +@msg not an action set file ({e}) inte en fil med åtgärdsuppsättning ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray profilens färgrymd {other} är inte RGB, CMYK eller Gray +@msg regular expression too large det reguljära uttrycket är för stort +@msg script file is not UTF-8 text: {value} skriptfilen är inte UTF-8-text: {value} +@msg stop {i}: {e} stopp {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) version {major}.{minor} av filer för färgruteutbyte stöds inte (endast 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors färgboken innehåller fler än {ASE_MAX_ENTRIES} färger +@msg the color book is cut short färgboken är avkortad +@msg the color book's names take more than {_1} MB färgbokens namn tar mer än {_1} MB +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution bilden skulle bli {w} × {h} pixlar; rasterexporter är begränsade till {MAX_RASTER_SIDE} pixlar per sida och {_1} megapixel: sänk skalan eller upplösningen +@msg the optimised image can't be read back: {e} den optimerade bilden kan inte läsas tillbaka: {e} +@msg the pixel buffer doesn't match the image size pixelbufferten stämmer inte med bildens storlek +@msg the preview: {e} förhandsgranskningen: {e} +@msg the request may not read the New Document profiles folder begäran får inte läsa mappen med profiler för nytt dokument +@msg the shapes are too degenerate to combine formerna är för degenererade för att kombineras +@msg the shapes cross themselves and each other too often to combine formerna korsar sig själva och varandra för ofta för att kombineras +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups filen för färgruteutbyte innehåller fler än {ASE_MAX_ENTRIES} färger och grupper +@msg the swatch exchange file is cut short filen för färgruteutbyte är avkortad +@msg the {_1} plate can't be read: {e} plåten {_1} kan inte läsas: {e} +@msg this binary DXF file is damaged or cut short den här binära DXF-filen är skadad eller avkortad +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that det här är en DWG-ritning, som inte kan läsas: spara den från CAD-appen som DXF och öppna den +@msg too many classes för många klasser +@msg too many groups för många grupper +@msg too many layers for PSD (at most 32767) för många lager för PSD (högst 32767) +@msg too many swatch exchange blocks för många block i filen för färgruteutbyte +@msg unknown colour profile: {0} okänd färgprofil: {0} +@msg {_1} clashes with a built-in profile name {_1} krockar med namnet på en inbyggd profil +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} pixlar är för stort för GIF (högst 65535 pixlar per sida): sänk upplösningen +@msg {_1}: no usable transform to/from sRGB {_1}: ingen användbar transformering till/från sRGB +@msg {name} is not a CMYK profile {name} är inte en CMYK-profil +@msg {name} is not an RGB profile {name} är inte en RGB-profil +@msg {path}: could not be read {path}: kunde inte läsas +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pixlar är för stort för JPEG (högst 65535 pixlar per sida): sänk upplösningen +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pixlar är för stort för Targa (högst 65535 pixlar per sida): sänk upplösningen +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pixlar är för stort för PSD (högst {MAX_SIDE} pixlar per sida): sänk upplösningen diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index c303810eb9..602898ecce 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2061,6 +2061,44 @@ fn recolor_notes_on_objects_left_unchanged_translate_with_their_counts() { } } +/// Spanish shows the image encoders', color profiles', swatch exchange files', Revolve's, DXF +/// import's, the script engine's and the parameter checks' errors in Spanish, with their values, +/// backticked names and accepted values kept as the English writes them. +#[test] +fn spanish_translates_encoder_profile_swatch_revolve_and_parameter_errors() { + let es = Lang::from_code("es").expect("registered"); + for (english, spanish) in [ + ("PNG encoding failed: 0 × 0 pixels is not a valid image size", "Error al codificar PNG: 0 × 0 píxeles no es un tamaño de imagen válido"), + ( + "70000 × 10 pixels is too large for GIF (at most 65535 pixels a side): lower the resolution", + "70000 × 10 píxeles es demasiado grande para GIF (como máximo 65535 píxeles por lado): baja la resolución", + ), + ("profile colour space XYZ is not RGB, CMYK or Gray", "el espacio de color XYZ del perfil no es RGB, CMYK ni Gray"), + ( + "color books in color space HSB aren't supported (only RGB, CMYK and Lab)", + "no se admiten libros de colores en el espacio de color HSB (solo RGB, CMYK y Lab)", + ), + ( + "the swatch exchange file holds more than 65536 colors and groups", + "el archivo de intercambio de muestras contiene más de 65536 colores y grupos", + ), + ( + "Revolve surface is too complex; reduce Segments or simplify the profile", + "La superficie de Revolución es demasiado compleja; reduce Segmentos o simplifica el perfil", + ), + ("regular expression too large", "expresión regular demasiado grande"), + ("this binary DXF file is damaged or cut short", "este archivo DXF binario está dañado o truncado"), + ( + "`view` is smallThumb, mediumThumb, largeThumb, smallList or largeList", + "`view` es smallThumb, mediumThumb, largeThumb, smallList o largeList", + ), + ("give `fill` or `stroke`, not both", "indica `fill` o `stroke`, no ambos"), + ("stop 2: oops", "parada 2: oops"), + ] { + assert_eq!(message(es, english), spanish); + } +} + #[test] fn package_status_message_templates_parse() { let c = Catalog::parse(concat!( diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 26822bf63e..7120d44707 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4049,6 +4049,14 @@ axis Both Her İkisi @msg a script is running: stop it or wait for it to finish bir betik çalışıyor: durdurun veya bitmesini bekleyin @msg this is a compiled script, which cannot be run; use its source text bu derlenmiş bir betik, çalıştırılamaz; kaynak metnini kullanın @msg the script is not UTF-8 text betik UTF-8 metni değil +@msg script file is not UTF-8 text: {value} betik dosyası UTF-8 metni değil: {value} +@msg a number cannot be read bir sayı okunamıyor +@msg regular expression too large düzenli ifade çok büyük +@msg too many classes çok fazla karakter sınıfı +@msg too many groups çok fazla grup +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` smallThumb, mediumThumb, largeThumb, smallList veya largeList olmalıdır @msg the script is too large to run betik çalıştırılamayacak kadar büyük @msg a drag or an undo group is open; finish it before running a script bir sürükleme veya geri alma grubu açık; betiği çalıştırmadan önce tamamlayın @msg the script left a document that cannot be kept betik, saklanamayan bir belge bıraktı @@ -4241,6 +4249,7 @@ axis Both Her İkisi @msg `{key}` must be a string `{key}` bir dize olmalıdır @msg `{key}` must be a profile name or null `{key}` bir profil adı ya da null olmalıdır @msg `{key}` must be a whole number `{key}` bir tam sayı olmalıdır +@msg `index` must be a whole number `index` bir tam sayı olmalıdır @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}`, {min} ile {max} arasında bir tam sayı olmalıdır ({x}'yi aldı) @msg `{key}` must be between {min} and {max} (got {x}) `{key}`, {min} ile {max} arasında olmalıdır ({x}'yi aldı) @msg `{key}` must be one of {_1} `{key}`, {_1}'den biri olmalıdır @@ -4355,6 +4364,12 @@ axis Both Her İkisi @msg file version {0} is newer than this VectorCraft supports ({VERSION}) dosya sürümü {0}, VectorCraft'ın desteklediğinden daha yenidir ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` serbest biçimli nokta {i} için `at` [x, y] ve geçerli bir `color` gerekir @msg freeform line {i} must be an array of point indices Serbest biçimli çizgi {i}, bir nokta indeks dizisi olmalıdır +@msg freeform line {i}: {e} serbest biçimli çizgi {i}: {e} +@msg freeform point {i}: {e} serbest biçimli nokta {i}: {e} +@msg a line can't run from a point to itself bir çizgi bir noktadan aynı noktaya uzanamaz +@msg a line needs at least two points bir çizgi en az iki nokta gerektirir +@msg stop {i}: {e} durak {i}: {e} +@msg give `{key}` or `{other}`, not both `{key}` veya `{other}` verin, ikisini birden değil @msg give both `start` and `end` [x, y] (or neither) hem `start` hem de `end` [x, y] verin (ya da hiçbirini) @msg give corners of the path: anchors where its sides meet at an angle yolun köşelerini verin: kenarlarının belli bir açıyla buluştuğu noktalara sabitleyin @msg give horizontal or vertical yatay veya dikey ver @@ -4481,6 +4496,7 @@ axis Both Her İkisi @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” yüklendi: {n} adım atlandı @msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}” yüklendi: bilinmeyen komutlu {n} adım korundu; ilki: “{action}”, adım {step}, `{command}` @msg not an action set file bir eylem seti dosyası değil +@msg not an action set file ({e}) bir eylem seti dosyası değil ({e}) @msg the action set file is larger than 8 MiB eylem seti dosyası 8 MiB'den büyük @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: başvuru {MAX_BYTES} bayttan büyük olur @msg the action set file holds more than 1,000 actions eylem seti dosyası 1.000'den fazla eylem içeriyor @@ -4514,6 +4530,34 @@ axis Both Her İkisi @msg not a VectorCraft file: {0} bir VectorCraft dosyası değil: {0} @msg not a VectorCraft shortcut set (missing `overrides`) VectorCraft kısayol seti değil (`overrides` eksik) @msg not a graphic style library (format `{_1}`) grafik stili kitaplığı değil (`{_1}` biçimi) +@msg not a graphic style library: {e} grafik stili kitaplığı değil: {e} +@msg not a swatch library (.vcswatches or .gpl) renk örneği kitaplığı değil (.vcswatches veya .gpl) +@msg not a swatch library (format `{_1}`) renk örneği kitaplığı değil (`{_1}` biçimi) +@msg not a swatch library: {e} renk örneği kitaplığı değil: {e} +# Swatch exchange files, color books and color profiles. +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft bir renk örneği değişim dosyasından en fazla {ASE_MAX_ENTRIES} renk ve grup okur; bu kitaplıkta {entries} tane var +@msg a swatch exchange block is too long bir renk örneği değişim bloğu çok uzun +@msg a swatch exchange name is too long bir renk örneği değişim adı çok uzun +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) renk örneği değişim sürümü {major}.{minor} desteklenmiyor (yalnızca 1.x) +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups renk örneği değişim dosyası {ASE_MAX_ENTRIES} renk ve gruptan fazlasını içeriyor +@msg the swatch exchange file is cut short renk örneği değişim dosyası kısa kesildi +@msg too many swatch exchange blocks çok fazla renk örneği değişim bloğu +@msg color book version {version} isn't supported (only 1) renk kitabı sürümü {version} desteklenmiyor (yalnızca 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) {other} renk alanındaki renk kitapları desteklenmiyor (yalnızca RGB, CMYK ve Lab) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors renk kitabı {ASE_MAX_ENTRIES} renkten fazlasını içeriyor +@msg the color book is cut short renk kitabı kısa kesildi +@msg the color book's names take more than {_1} MB renk kitabının adları {_1} MB'tan fazla yer kaplıyor +@msg cannot read profile: {0} profil okunamıyor: {0} +@msg invalid ICC profile: {0} geçersiz ICC profili: {0} +@msg unknown colour profile: {0} bilinmeyen renk profili: {0} +@msg its localized names take more than {_1} KB yerelleştirilmiş adları {_1} KB'tan fazla yer kaplıyor +@msg its tag table lists the `{_1}` tag more than once etiket tablosu `{_1}` etiketini birden fazla kez listeliyor +@msg profile colour space {other} is not RGB, CMYK or Gray profil renk alanı {other}; RGB, CMYK veya Gray değil +@msg grayscale profiles can't be working spaces yet gri tonlamalı profiller henüz çalışma alanı olarak kullanılamaz +@msg {_1} clashes with a built-in profile name {_1}, yerleşik bir profil adıyla çakışıyor +@msg {_1}: no usable transform to/from sRGB {_1}: sRGB'ye veya sRGB'den kullanılabilir bir dönüşüm yok +@msg {name} is not a CMYK profile {name} bir CMYK profili değil +@msg {name} is not an RGB profile {name} bir RGB profili değil @msg not a shortcut set: the text doesn't start with a slash name and an opening brace bir kısayol seti değil: metin eğik çizgi adı ve açılış paranteziyle başlamıyor @msg not a stored central record saklanan merkezi bir kayıt değil @msg not a valid plug-in module: {0} geçerli bir eklenti modülü değil: {0} @@ -4698,6 +4742,8 @@ axis Both Her İkisi @msg this file's PostScript couldn't be read ({why}): {what} bu dosyanın PostScript'i okunamadı ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens bu formatta bir çalışma yüzeyi bulunur: birkaçını PDF olarak veya document.exportForScreens ile dışa aktarın @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that bu okunamayan bir DWG çizimidir: CAD uygulamasından ASCII DXF olarak kaydedin ve açın. +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that bu okunamayan bir DWG çizimidir: CAD uygulamasından DXF olarak kaydedin ve onu açın +@msg this binary DXF file is damaged or cut short bu ikili DXF dosyası hasarlı veya kısa kesilmiş @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS bu bir PostScript dosyasıdır (eski bir formatta kaydedilmiş veya PDF uyumluluğu olmayan bir .ai veya bir EPS dosyası), PDF değil: Dosya › Aç ve Dosya › Yerleştir, EPS olarak oku @msg this is not a PostScript file bu bir PostScript dosyası değil @msg tool shortcuts must be a single key (optionally with Shift) araç kısayolları tek tuş olmalıdır (isteğe bağlı olarak Shift ile) @@ -4954,6 +5000,16 @@ axis Both Her İkisi @msg automation path rejected: cannot resolve {path}: {e} otomasyon yolu reddedildi: {path} çözülemiyor: {e} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} otomasyon yolu reddedildi: var olmayan bir klasörün altında üst öğe geçişi: {path} @msg automation path rejected: reserved device names are not allowed: {path} otomasyon yolu reddedildi: ayrılmış cihaz adlarına izin verilmiyor: {path} +@msg cannot open the automation {kind} root `{_1}`: {e} otomasyon {kind} kökü `{_1}` açılamıyor: {e} +@msg cannot align zip entries to {align} bytes zip girdileri {align} bayta hizalanamıyor +@msg file name too long to zip: {_1} dosya adı zip için çok uzun: {_1} +@msg file name too long to zip: {name} dosya adı zip için çok uzun: {name} +@msg can't open “{name}”: {e} “{name}” açılamıyor: {e} +@msg {path}: could not be read {path}: okunamadı +@msg the request may not read the New Document profiles folder isteğin Yeni Belge profilleri klasörünü okuma izni yok +@msg its objects are nested too deep ({e}) nesneleri çok derin iç içe geçmiş ({e}) +@msg an image that couldn't be read was left out okunamayan bir görüntü dışarıda bırakıldı +@msg the {_1} plate can't be read: {e} {_1} baskı plakası okunamıyor: {e} @msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode Çok kanallı PSD dosyaları okunamıyor: RGB, CMYK veya Gri Tonlama modunda kaydedin @msg not a PSD file PSD dosyası değil @msg the PSD file has an unknown colour mode ({m}) PSD dosyasında bilinmeyen bir renk modu var ({m}) @@ -4985,6 +5041,42 @@ axis Both Her İkisi @msg the JPEG XL container is too large to read JPEG XL kapsayıcısı okunamayacak kadar büyük @msg CMYK JPEG XL images can't be read CMYK JPEG XL görüntüleri okunamıyor @msg the JPEG XL file can't be read: {e} JPEG XL dosyası okunamıyor: {e} +# Image export: encoders, sizes and options. +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP derinliği {_1}: piksel başına 1, 4, 8, 16, 24 veya 32 bit olmalıdır +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP kodlaması başarısız oldu: {width} × {height} piksel biçime veya piksel arabelleğine sığmıyor +@msg GIF encoding failed: the palette image is malformed GIF kodlaması başarısız oldu: palet görüntüsü bozuk biçimli +@msg GIF encoding failed: {e} GIF kodlaması başarısız oldu: {e} +@msg JPEG colour profile: {e} JPEG renk profili: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG kodlaması başarısız oldu: piksel arabelleği görüntü boyutuyla eşleşmiyor +@msg JPEG encoding failed: {e} JPEG kodlaması başarısız oldu: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size PNG kodlaması başarısız oldu: piksel arabelleği görüntü boyutuyla eşleşmiyor +@msg PNG encoding failed: the palette image is malformed PNG kodlaması başarısız oldu: palet görüntüsü bozuk biçimli +@msg PNG encoding failed: {e} PNG kodlaması başarısız oldu: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG kodlaması başarısız oldu: {width} × {height} piksel geçerli bir görüntü boyutu değil +@msg PSD colour profile: {e} PSD renk profili: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD kodlaması başarısız oldu: piksel arabelleği görüntü boyutuyla eşleşmiyor +@msg TIFF colour profile: {e} TIFF renk profili: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF kodlaması başarısız oldu: piksel arabelleği görüntü boyutuyla eşleşmiyor +@msg TIFF encoding failed: {e} TIFF kodlaması başarısız oldu: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa derinliği {_1}: piksel başına 16, 24 veya 32 bit olmalıdır +@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa kodlaması başarısız oldu: piksel arabelleği görüntü boyutuyla eşleşmiyor +@msg WebP encoding failed: {e} WebP kodlaması başarısız oldu: {e} +@msg the pixel buffer doesn't match the image size piksel arabelleği görüntü boyutuyla eşleşmiyor +@msg too many layers for PSD (at most 32767) PSD için çok fazla katman (en fazla 32767) +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} piksel GIF için çok büyük (kenar başına en fazla 65535 piksel): çözünürlüğü düşürün +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} piksel JPEG için çok büyük (kenar başına en fazla 65535 piksel): çözünürlüğü düşürün +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} piksel Targa için çok büyük (kenar başına en fazla 65535 piksel): çözünürlüğü düşürün +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} piksel PSD için çok büyük (kenar başına en fazla {MAX_SIDE} piksel): çözünürlüğü düşürün +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution görüntü {w} × {h} piksel olurdu; raster dışa aktarmalar kenar başına {MAX_RASTER_SIDE} piksel ve {_1} megapiksel ile sınırlıdır: ölçeği veya çözünürlüğü düşürün +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels {sw} × {sh} boyutundaki bir görüntü {width} × {height} piksele yeniden boyutlandırılamaz +@msg a {width} × {height} image is too large {width} × {height} boyutundaki bir görüntü çok büyük +@msg a {width} × {height} image: {e} {width} × {height} boyutundaki bir görüntü: {e} +@msg an image could not be written: {e} bir görüntü yazılamadı: {e} +@msg invalid scale {scale} geçersiz ölçek {scale} +@msg SVG options: {e} SVG seçenekleri: {e} +@msg Save for Web settings: {e} Web için Kaydet ayarları: {e} +@msg the optimised image can't be read back: {e} en iyileştirilmiş görüntü geri okunamıyor: {e} +@msg the preview: {e} önizleme: {e} Video video De-Interlace… Taramasızlaştırma… De-Interlace Taramasızlaştırma @@ -5272,6 +5364,31 @@ lighting Light Işık @msg {label} draws no faces for this object {label} bu nesne için hiçbir yüz çizmez @msg the wireframe has more than {MAX_WIRE_EDGES} edges tel kafeste {MAX_WIRE_EDGES} kenardan fazla var @msg The 3D surface is too complex; simplify the shape 3 boyutlu yüzey çok karmaşık; şekli basitleştir +@msg 3D effects require finite coordinates within 100 million points 3D efektleri 100 milyon pt sınırı içinde sonlu koordinatlar gerektirir +@msg 3D effects require one to ten lights 3D efektleri bir ile on arasında ışık gerektirir +@msg The 3D projection is not finite 3D izdüşümü sonlu değil +@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D gölgesi çok karmaşık; Gölgeler'i kapatın veya resmi basitleştirin +@msg The shape contains invalid coordinates Şekil geçersiz koordinatlar içeriyor +@msg Revolve profile contains invalid coordinates Döndür profili geçersiz koordinatlar içeriyor +@msg Revolve profile is too complex Döndür profili çok karmaşık +@msg Revolve projection is not finite Döndür izdüşümü sonlu değil +@msg Revolve requires finite profile coordinates within 100 million points Döndür, 100 milyon pt sınırı içinde sonlu profil koordinatları gerektirir +@msg Revolve surface is too complex; reduce Segments or simplify the profile Döndür yüzeyi çok karmaşık; Segmentler değerini azaltın veya profili basitleştirin +@msg Revolve surface is too complex; simplify the profile Döndür yüzeyi çok karmaşık; profili basitleştirin +@msg Revolve visibility cannot split the projected boundary Döndür görünürlüğü izdüşümü alınan sınırı bölemiyor +@msg Revolve visibility creates too many fragments Döndür görünürlüğü çok fazla parça oluşturuyor +@msg Revolve visibility creates too many vector anchors Döndür görünürlüğü çok fazla vektör bağlantı noktası oluşturuyor +@msg Revolve visibility exceeds its geometry work limit Döndür görünürlüğü geometri işlem sınırını aşıyor +@msg Revolve visibility exceeds its spatial index limit Döndür görünürlüğü uzamsal dizin sınırını aşıyor +@msg Revolve visibility has too many boundary crossings Döndür görünürlüğünde çok fazla sınır kesişimi var +@msg Revolve visibility has too many input anchors Döndür görünürlüğünde çok fazla girdi bağlantı noktası var +@msg Revolve visibility requires convex cap sectors Döndür görünürlüğü dışbükey kapak dilimleri gerektirir +@msg Revolve visibility requires finite projected faces Döndür görünürlüğü sonlu izdüşüm yüzleri gerektirir +@msg Too many Revolve faces for visibility evaluation Görünürlük değerlendirmesi için çok fazla Döndür yüzü var +@msg input contains NaN or infinite coordinates girdi NaN veya sonsuz koordinatlar içeriyor +@msg input path could not be closed girdi yolu kapatılamadı +@msg the shapes are too degenerate to combine şekiller birleştirilemeyecek kadar yozlaşmış +@msg the shapes cross themselves and each other too often to combine şekiller kendilerini ve birbirlerini birleştirilemeyecek kadar sık kesiyor Transform Objects Nesneleri Dönüştür @msg unknown parameter `{key}`; did you mean `{near}`? bilinmeyen parametre `{key}`; `{near}` mi demek istediniz? @msg unknown parameter `{key}`; the command takes no parameters bilinmeyen parametre `{key}`; komut hiçbir parametre almaz @@ -7428,6 +7545,12 @@ breakText Glyphs Glifler @msg open the practice sample of `{id}` first (see learn.openSample) önce `{id}` öğreticisinin alıştırma örneğini açın (bkz. learn.openSample) @msg finish `{id}` first (see learn.finish) önce `{id}` öğreticisini bitirin (bkz. learn.finish) @msg `{id}` is the last tutorial in its skill `{id}`, becerisinin son öğreticisidir + Reset progress İlerlemeyi sıfırla + Mark Tutorial Step Öğretici Adımını İşaretle + Reset Learn Progress Öğrenme İlerlemesini Sıfırla +@msg missing `step` (see learn.show) `step` eksik (bkz. learn.show) +@msg `step` must be a step number from 0 to {last} `step`, 0 ile {last} arasında bir adım numarası olmalıdır +@msg `{id}` is finished (see learn.resetProgress) `{id}` bitirildi (bkz. learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Ertelenen Efektleri Yenile diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index aa8d0c56f9..84f296b0a6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -7082,6 +7082,12 @@ breakText Glyphs Гліфи @msg open the practice sample of `{id}` first (see learn.openSample) спочатку відкрийте навчальний приклад уроку `{id}` (див. learn.openSample) @msg finish `{id}` first (see learn.finish) спочатку завершіть `{id}` (див. learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` є останнім уроком своєї навички + Reset progress Скинути прогрес + Mark Tutorial Step Позначити крок уроку + Reset Learn Progress Скинути прогрес навчання +@msg missing `step` (see learn.show) відсутній `step` (див. learn.show) +@msg `step` must be a step number from 0 to {last} `step` має бути номером кроку від 0 до {last} +@msg `{id}` is finished (see learn.resetProgress) урок `{id}` завершено (див. learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Оновити відкладені ефекти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7b7480cbfd..e8b9e5758f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -7083,6 +7083,12 @@ breakText Glyphs Glyph @msg open the practice sample of `{id}` first (see learn.openSample) hãy mở tệp mẫu thực hành của `{id}` trước (xem learn.openSample) @msg finish `{id}` first (see learn.finish) hãy hoàn tất `{id}` trước (xem learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` là hướng dẫn cuối cùng trong kỹ năng của nó + Reset progress Đặt lại tiến độ + Mark Tutorial Step Đánh dấu bước hướng dẫn + Reset Learn Progress Đặt lại tiến độ học +@msg missing `step` (see learn.show) thiếu `step` (xem learn.show) +@msg `step` must be a step number from 0 to {last} `step` phải là số bước từ 0 đến {last} +@msg `{id}` is finished (see learn.resetProgress) `{id}` đã hoàn tất (xem learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects Làm mới hiệu ứng bị hoãn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e1ec9f659c..719968086f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -7311,6 +7311,12 @@ breakText Glyphs 字形 @msg open the practice sample of `{id}` first (see learn.openSample) 请先打开 `{id}` 的练习示例 (请参阅 learn.openSample) @msg finish `{id}` first (see learn.finish) 请先完成 `{id}` (请参阅 learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` 是其技能的最后一个教程 + Reset progress 重置进度 + Mark Tutorial Step 标记教程步骤 + Reset Learn Progress 重置学习进度 +@msg missing `step` (see learn.show) 缺少 `step` (请参阅 learn.show) +@msg `step` must be a step number from 0 to {last} `step` 必须是 0 到 {last} 之间的步骤编号 +@msg `{id}` is finished (see learn.resetProgress) `{id}` 已完成 (请参阅 learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects 刷新延迟的效果 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 264a252f18..121a8b8c02 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -7119,6 +7119,12 @@ breakText Glyphs 字符 @msg open the practice sample of `{id}` first (see learn.openSample) 請先開啟 `{id}` 的練習範例 (請參閱 learn.openSample) @msg finish `{id}` first (see learn.finish) 請先完成 `{id}` (請參閱 learn.finish) @msg `{id}` is the last tutorial in its skill `{id}` 是其技能的最後一個教學課程 + Reset progress 重設進度 + Mark Tutorial Step 標記教學課程步驟 + Reset Learn Progress 重設學習進度 +@msg missing `step` (see learn.show) 未提供 `step` (請參閱 learn.show) +@msg `step` must be a step number from 0 to {last} `step` 必須是 0 到 {last} 之間的步驟編號 +@msg `{id}` is finished (see learn.resetProgress) `{id}` 已完成 (請參閱 learn.resetProgress) # Deferred plug-in effects Refresh Deferred Effects 重新整理延後的效果 @@ -7149,6 +7155,122 @@ breakText Glyphs 字符 Grommet Inset 扣眼邊距 Diameter 直徑 +# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. +@msg 3D effects require finite coordinates within 100 million points 3D 效果需要位於 1 億點以內的有限座標 +@msg 3D effects require one to ten lights 3D 效果需要 1 到 10 個光源 +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP 色彩深度 {_1}:須為每像素 1、4、8、16、24 或 32 位元 +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP 編碼失敗:{width} × {height} 像素不符合格式或像素緩衝區 +@msg GIF encoding failed: the palette image is malformed GIF 編碼失敗:調色盤影像格式錯誤 +@msg GIF encoding failed: {e} GIF 編碼失敗:{e} +@msg JPEG colour profile: {e} JPEG 色彩描述檔:{e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG 編碼失敗:像素緩衝區與影像尺寸不符 +@msg JPEG encoding failed: {e} JPEG 編碼失敗:{e} +@msg PNG encoding failed: pixel buffer doesn't match the image size PNG 編碼失敗:像素緩衝區與影像尺寸不符 +@msg PNG encoding failed: the palette image is malformed PNG 編碼失敗:調色盤影像格式錯誤 +@msg PNG encoding failed: {e} PNG 編碼失敗:{e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG 編碼失敗:{width} × {height} 像素不是有效的影像尺寸 +@msg PSD colour profile: {e} PSD 色彩描述檔:{e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD 編碼失敗:像素緩衝區與影像尺寸不符 +@msg Revolve profile contains invalid coordinates 迴轉的輪廓包含無效的座標 +@msg Revolve profile is too complex 迴轉的輪廓過於複雜 +@msg Revolve projection is not finite 迴轉的投影不是有限值 +@msg Revolve requires finite profile coordinates within 100 million points 迴轉需要位於 1 億點以內的有限輪廓座標 +@msg Revolve surface is too complex; reduce Segments or simplify the profile 迴轉曲面過於複雜;請減少「分段數」或簡化輪廓 +@msg Revolve surface is too complex; simplify the profile 迴轉曲面過於複雜;請簡化輪廓 +@msg Revolve visibility cannot split the projected boundary 迴轉的可見性計算無法分割投影邊界 +@msg Revolve visibility creates too many fragments 迴轉的可見性計算產生過多片段 +@msg Revolve visibility creates too many vector anchors 迴轉的可見性計算產生過多向量錨點 +@msg Revolve visibility exceeds its geometry work limit 迴轉的可見性計算超出幾何運算量上限 +@msg Revolve visibility exceeds its spatial index limit 迴轉的可見性計算超出空間索引上限 +@msg Revolve visibility has too many boundary crossings 迴轉的可見性計算有過多邊界交叉 +@msg Revolve visibility has too many input anchors 迴轉的可見性計算有過多輸入錨點 +@msg Revolve visibility requires convex cap sectors 迴轉的可見性計算需要凸形的端蓋扇區 +@msg Revolve visibility requires finite projected faces 迴轉的可見性計算需要有限的投影面 +@msg SVG options: {e} SVG 選項:{e} +@msg Save for Web settings: {e} 儲存為網頁用設定:{e} +@msg TIFF colour profile: {e} TIFF 色彩描述檔:{e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF 編碼失敗:像素緩衝區與影像尺寸不符 +@msg TIFF encoding failed: {e} TIFF 編碼失敗:{e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa 色彩深度 {_1}:須為每像素 16、24 或 32 位元 +@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa 編碼失敗:像素緩衝區與影像尺寸不符 +@msg The 3D projection is not finite 3D 投影不是有限值 +@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D 陰影過於複雜;請關閉「陰影」或簡化圖稿 +@msg The shape contains invalid coordinates 形狀包含無效的座標 +@msg Too many Revolve faces for visibility evaluation 迴轉的面過多,無法計算可見性 +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft 從色票交換檔案最多讀取 {ASE_MAX_ENTRIES} 個顏色和群組,而此資料庫有 {entries} 個 +@msg WebP encoding failed: {e} WebP 編碼失敗:{e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`:{why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` 必須是 smallThumb、mediumThumb、largeThumb、smallList 或 largeList +@msg a line can't run from a point to itself 線條不能從一個點連到同一個點 +@msg a line needs at least two points 線條至少需要兩個點 +@msg a number cannot be read 無法讀取數字 +@msg a swatch exchange block is too long 色票交換區塊過長 +@msg a swatch exchange name is too long 色票交換名稱過長 +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels {sw} × {sh} 的影像無法調整為 {width} × {height} 像素 +@msg a {width} × {height} image is too large {width} × {height} 的影像過大 +@msg a {width} × {height} image: {e} {width} × {height} 的影像:{e} +@msg an image could not be written: {e} 無法寫入影像:{e} +@msg an image that couldn't be read was left out 已略過無法讀取的影像 +@msg can't open “{name}”: {e} 無法開啟「{name}」:{e} +@msg cannot align zip entries to {align} bytes 無法將 ZIP 項目對齊至 {align} 位元組 +@msg cannot open the automation {kind} root `{_1}`: {e} 無法開啟自動化 {kind} 根目錄 `{_1}`:{e} +@msg cannot read profile: {0} 無法讀取描述檔:{0} +@msg color book version {version} isn't supported (only 1) 不支援色票簿版本 {version} (僅支援 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) 不支援色彩空間為 {other} 的色票簿 (僅支援 RGB、CMYK 和 Lab) +@msg file name too long to zip: {_1} 檔案名稱過長,無法壓縮為 ZIP:{_1} +@msg file name too long to zip: {name} 檔案名稱過長,無法壓縮為 ZIP:{name} +@msg freeform line {i}: {e} 任意形狀線條 {i}:{e} +@msg freeform point {i}: {e} 任意形狀點 {i}:{e} +@msg give `{key}` or `{other}`, not both 請提供 `{key}` 或 `{other}`,不能同時提供兩者 +@msg grayscale profiles can't be working spaces yet 灰階描述檔目前還不能作為工作空間 +@msg input contains NaN or infinite coordinates 輸入包含 NaN 或無限大的座標 +@msg input path could not be closed 無法封閉輸入路徑 +@msg invalid ICC profile: {0} 無效的 ICC 描述檔:{0} +@msg invalid scale {scale} 無效的縮放比例 {scale} +@msg its localized names take more than {_1} KB 其本地化名稱超過 {_1} KB +@msg its objects are nested too deep ({e}) 其物件巢狀層級過深 ({e}) +@msg its tag table lists the `{_1}` tag more than once 其標記表多次列出 `{_1}` 標記 +@msg not a graphic style library: {e} 不是繪圖樣式資料庫:{e} +@msg not a swatch library (.vcswatches or .gpl) 不是色票資料庫 (.vcswatches 或 .gpl) +@msg not a swatch library (format `{_1}`) 不是色票資料庫 (格式為 `{_1}`) +@msg not a swatch library: {e} 不是色票資料庫:{e} +@msg not an action set file ({e}) 不是動作組檔案 ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray 描述檔的色彩空間 {other} 不是 RGB、CMYK 或灰階 +@msg regular expression too large 規則運算式過大 +@msg script file is not UTF-8 text: {value} 指令碼檔案不是 UTF-8 文字:{value} +@msg stop {i}: {e} 色標 {i}:{e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) 不支援色票交換版本 {major}.{minor} (僅支援 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors 色票簿包含超過 {ASE_MAX_ENTRIES} 個顏色 +@msg the color book is cut short 色票簿不完整 +@msg the color book's names take more than {_1} MB 色票簿的名稱超過 {_1} MB +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution 影像會是 {w} × {h} 像素;點陣圖匯出的上限為每邊 {MAX_RASTER_SIDE} 像素及 {_1} 百萬像素:請降低縮放比例或解析度 +@msg the optimised image can't be read back: {e} 無法讀回最佳化後的影像:{e} +@msg the pixel buffer doesn't match the image size 像素緩衝區與影像尺寸不符 +@msg the preview: {e} 預覽:{e} +@msg the request may not read the New Document profiles folder 此要求不得讀取新增文件描述檔資料夾 +@msg the shapes are too degenerate to combine 形狀過度退化,無法合併 +@msg the shapes cross themselves and each other too often to combine 形狀自身及彼此之間交叉過多,無法合併 +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups 色票交換檔案包含超過 {ASE_MAX_ENTRIES} 個顏色和群組 +@msg the swatch exchange file is cut short 色票交換檔案不完整 +@msg the {_1} plate can't be read: {e} 無法讀取 {_1} 色版:{e} +@msg this binary DXF file is damaged or cut short 此二進位 DXF 檔案已損毀或不完整 +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that 這是 DWG 圖面,無法讀取:請在 CAD 應用程式中將其儲存為 DXF,再開啟該檔案 +@msg too many classes 字元類別過多 +@msg too many groups 群組過多 +@msg too many layers for PSD (at most 32767) PSD 的圖層過多 (最多 32767 個) +@msg too many swatch exchange blocks 色票交換區塊過多 +@msg unknown colour profile: {0} 未知的色彩描述檔:{0} +@msg {_1} clashes with a built-in profile name {_1} 與內建描述檔名稱衝突 +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} 像素對 GIF 來說過大 (每邊最多 65535 像素):請降低解析度 +@msg {_1}: no usable transform to/from sRGB {_1}:沒有可與 sRGB 互相轉換的可用轉換 +@msg {name} is not a CMYK profile {name} 不是 CMYK 描述檔 +@msg {name} is not an RGB profile {name} 不是 RGB 描述檔 +@msg {path}: could not be read {path}:無法讀取 +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} 像素對 JPEG 來說過大 (每邊最多 65535 像素):請降低解析度 +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} 像素對 Targa 來說過大 (每邊最多 65535 像素):請降低解析度 +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} 像素對 PSD 來說過大 (每邊最多 {MAX_SIDE} 像素):請降低解析度 + # Status and error messages of commands, scripts, file import and export, and plug-ins. @msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions 嚴格模式的函式不能使用 'caller'、'callee' 和 'arguments' @msg Alignment must be one of the choices 對齊方式必須是選項之一 diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index af9252c333..d43d17220c 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -667,7 +667,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "learn.list", "List Tutorials", "", - "{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}", + "{} the Learn panel's skills in the order its Skills tab lists them, with the progress its rings, counts and check marks show: {skills: [{id, title, done: number of its tutorials finished, total: number of its tutorials, tutorials: [{id, title, steps: number of steps, finished: bool, completedSteps: [zero-based indexes of the steps marked done, every step once finished]}]}]}", ), ( "learn.skills", @@ -693,11 +693,17 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tutorial: id (see learn.list)} open that tutorial's practice sample as a new untitled document, which becomes the active one, and move the Learn view to the tutorial, as its View more button does; each call opens another copy and open documents stay as they are; a missing, non-string or unknown id is an error and changes nothing; returns {tutorial, index, title} of the new document", ), + ( + "learn.markStep", + "Mark Tutorial Step", + "", + "{tutorial: id (see learn.list), step: zero-based step number, complete?: bool} mark a step of a tutorial whose sample learn.openSample opened as done (true, omitted or null) or not done (false), as the check box before the step does; marking does not finish the tutorial; a missing, non-string or unknown id, a missing step or one the tutorial does not have, a complete that is not true or false, a tutorial whose sample was not opened, or a finished tutorial is an error and changes nothing; the preferences keep the marks; returns {tutorial, step, complete}", + ), ( "learn.finish", "Finish Tutorial", "", - "{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, as its Finish tutorial button does; finishing it again keeps one entry; a missing, non-string or unknown id, or a tutorial whose sample was not opened, is an error and changes nothing; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}", + "{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, with all its steps done, as its Finish tutorial button does; finishing it again keeps one entry, and needs no sample after a restart; a missing, non-string or unknown id, or a tutorial not finished whose sample was not opened, is an error and changes nothing; the preferences keep the tutorials finished; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}", ), ( "learn.next", @@ -705,6 +711,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tutorial: id (see learn.list)} show the Learn panel on the tutorial after a finished one in the same skill, as its Next Up button does, without opening a sample; a missing, non-string or unknown id, a tutorial not finished, or a skill's last tutorial is an error and changes nothing; returns {skill, tutorial, title} of the next tutorial", ), + ( + "learn.resetProgress", + "Reset Learn Progress", + "", + "{} clear the Learn panel's progress, every tutorial finished and every step marked done, as Reset progress under the list of skills does; open documents, opened practice samples and the Learn view stay as they are; returns what learn.list returns", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 71019385b6..0b0435b3c6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -529,6 +529,21 @@ const DRAG_SCROLL_EDGE: f32 = 28.0; /// the list's edge. const DRAG_SCROLL_SPEED: (f32, f32) = (60.0, 280.0); +/// The scrolling [`scroll_at_edges`] asks for, in points per second (positive shows earlier rows), +/// with the pointer `depth` points below the top of a list view `view_height` points tall. Each +/// band is [`DRAG_SCROLL_EDGE`] deep, or half the view in a view shorter than two bands, so the +/// pointer is in one band at most and the middle of a short view scrolls neither way. A view with +/// no height, or a value that isn't a number, gives 0. +fn drag_scroll_speed(view_height: f32, depth: f32) -> f32 { + if !(view_height.is_finite() && view_height > 0.0) { + return 0.0; + } + let band = DRAG_SCROLL_EDGE.min(view_height / 2.0); + let (slow, more) = DRAG_SCROLL_SPEED; + let speed = |depth: f32| if (0.0..band).contains(&depth) { slow + more * (1.0 - depth / band) } else { 0.0 }; + speed(depth) - speed(view_height - depth) +} + /// While something is dragged near the top or bottom edge of a scrolled list (`ui`: its contents), /// scroll that way, faster nearer the edge, and draw the next frame so a pointer held still keeps /// it scrolling. The pointer must be over the list's width. Each frame scrolls by the time it @@ -540,9 +555,7 @@ pub(crate) fn scroll_at_edges(ui: &mut Ui) { if !view.x_range().contains(pos.x) { return; } - let (slow, more) = DRAG_SCROLL_SPEED; - let speed = |depth: f32| if (0.0..DRAG_SCROLL_EDGE).contains(&depth) { slow + more * (1.0 - depth / DRAG_SCROLL_EDGE) } else { 0.0 }; - let per_second = speed(pos.y - view.top()) - speed(view.bottom() - pos.y); + let per_second = drag_scroll_speed(view.height(), pos.y - view.top()); if per_second != 0.0 { let dt = ui.input(|i| i.stable_dt).min(0.1); ui.scroll_with_delta_animation(vec2(0.0, per_second * dt), egui::style::ScrollAnimation::none()); @@ -683,6 +696,29 @@ mod tests { let tint = Paint::Solid { color: Color::gray(0.5), swatch: Some("Ink".into()), tint: 0.5 }; assert_eq!(paint_params(&tint), json!({"swatch": "Ink", "tint": 50.0}), "a tint keeps its percentage"); } + + #[test] + fn drag_scrolling_in_a_short_list_goes_toward_the_nearer_edge() { + let near = |a: f32, b: f32| (a - b).abs() < 1e-3; + // A 40 pt view: each band is 20 pt deep, so the pointer is in one band at most. + for (depth, want) in [(5.0, 270.0), (15.0, 130.0), (20.0, 0.0), (25.0, -130.0), (35.0, -270.0)] { + let got = drag_scroll_speed(40.0, depth); + assert!(near(got, want), "40 pt view, pointer {depth} pt down: {got} pt/s, want {want}"); + } + // Just outside a 20 pt view, the pointer does not scroll the list toward the far edge. + for depth in [-1.0, 21.0] { + assert_eq!(drag_scroll_speed(20.0, depth), 0.0, "20 pt view, pointer {depth} pt down"); + } + // From 56 pt up, the bands stay 28 pt deep and the speeds are unchanged. + for (h, depth, want) in [(100.0, 5.0, 290.0), (100.0, 95.0, -290.0), (100.0, 50.0, 0.0), (56.0, 0.0, 340.0), (56.0, 27.0, 70.0)] { + let got = drag_scroll_speed(h, depth); + assert!(near(got, want), "{h} pt view, pointer {depth} pt down: {got} pt/s, want {want}"); + } + for h in [0.0, -10.0, f32::NAN, f32::INFINITY] { + assert_eq!(drag_scroll_speed(h, 5.0), 0.0, "view height {h}"); + } + assert_eq!(drag_scroll_speed(40.0, f32::NAN), 0.0); + } } #[cfg(test)] mod tests_appearance; diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index eff4796601..4bab22d52e 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -6216,10 +6216,10 @@ mod tests { } } - /// A drag with the Selection tool (a committed preview) or the Rectangle tool (a command the - /// tool runs) that takes a recording past 10,000 steps keeps its edit and the journal, the - /// status bar names the limit as the tool ends, and Stop Playing/Recording keeps the 10,000 - /// steps that fit. One that fills the action exactly names no limit. + /// A drag with the Selection tool or the Rectangle tool (each commits its preview) that takes a + /// recording past 10,000 steps keeps its edit and the journal, the status bar names the limit + /// as the tool ends, and Stop Playing/Recording keeps the 10,000 steps that fit. One that fills + /// the action exactly names no limit. #[test] fn tool_commit_checks_recording_step_limit() { let steps = "the action already has 10,000 steps"; @@ -6272,6 +6272,115 @@ mod tests { } } + /// Drags the Enclosing Lasso away from the selected rectangle, which runs `select.set` with no + /// objects, and checks that the tool journaled that one step and cleared the selection. + fn lasso_nothing(app: &mut VectorcraftApp) { + app.select_tool(vectorcraft_tools::addons::enclosing_lasso::ID); + assert_eq!(app.session.tool_id(), vectorcraft_tools::addons::enclosing_lasso::ID); + assert!(!app.session.active().unwrap().selection.objects.is_empty()); + let journal = app.session.journal.len(); + tool_drag(app, (100.0, 80.0), (120.0, 80.0)); + assert_eq!(app.session.journal.len(), journal + 1, "the tool journals one step"); + assert_eq!(app.session.journal.last(), Some(&("select.set".to_string(), json!({"ids": []})))); + assert!(app.session.active().unwrap().selection.objects.is_empty(), "the edit stays"); + } + + /// A command a tool runs (the Enclosing Lasso's `select.set`) that takes a recording past + /// 10,000 steps keeps its edit and the journal, the status bar names the limit as the tool + /// ends, the next recorded command does not run, and Stop Playing/Recording keeps the 10,000 + /// steps that fit. One that fills the action exactly names no limit. + #[test] + fn tool_exec_checks_recording_step_limit() { + let steps = "the action already has 10,000 steps"; + for filler in [files::MAX_ACTION_STEPS, files::MAX_ACTION_STEPS - 1] { + let (mut app, _) = app_with_three_moves(); + recording_with(&mut app, filler); + app.ui.status.clear(); + lasso_nothing(&mut app); + if filler < files::MAX_ACTION_STEPS { + assert_eq!(app.ui.status, "", "10,000 steps fit"); + continue; + } + assert_eq!(app.ui.status, steps, "the status bar names the limit now"); + assert!(app.ui.recording.is_some(), "the recording goes on"); + let (journal, art) = (app.session.journal.len(), app.session.active().unwrap().doc.art_bounds()); + let r = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 20, "height": 20})); + assert_eq!(r.unwrap_err(), steps, "no later command runs"); + assert_eq!(app.session.journal.len(), journal); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), art, "no rectangle is drawn"); + assert_eq!(app.session.journal.since(recording_start(&app).unwrap()).len(), files::MAX_ACTION_STEPS + 1, "the journal keeps the step"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), files::MAX_ACTION_STEPS); + } + } + + /// A command a tool runs (the Enclosing Lasso's `select.set`) whose step gives the recording's + /// set a file larger than 8 MiB keeps its edit, the status bar names the limit as the tool + /// ends, and Stop Playing/Recording leaves the step out of a set that Load Actions… reads. + #[test] + fn tool_exec_checks_recording_byte_limit() { + let size = "the action set would be larger than 8 MiB"; + let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({})).unwrap(); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + app.ui.action_sets[0].actions[0].steps = vec![pad(room)]; + app.ui.recording_into = Some(app.ui.action_sets[0].actions[0].id); + app.ui.status.clear(); + lasso_nothing(&mut app); + assert_eq!(app.ui.status, size, "the status bar names the limit now"); + assert_eq!(app.session.journal.since(recording_start(&app).unwrap()).len(), 1, "the journal keeps the step"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 1, "the step is left out"); + let saved = files::write_set(&app.ui.action_sets[0]); + assert!(saved.len() <= files::MAX_SET_FILE); + assert!(files::read_set(&saved, "Test").is_ok()); + } + + /// The place cursor's `file.place` that takes a recording past 10,000 steps keeps the placed + /// file and the journal, and the status bar names the limit once the file is placed: on the + /// release when the file is read at once, and on the frame that places it when it is read off + /// the UI thread. Stop Playing/Recording keeps the 10,000 steps that fit. + #[test] + fn place_cursor_exec_checks_recording_step_limit_on_completion() { + let steps = "the action already has 10,000 steps"; + let mut png = vec![]; + image::RgbImage::new(8, 8).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let placed = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + st.doc.layers.iter().flat_map(|l| l.children().into_iter().flatten()).filter(|n| n.name.as_deref() == Some("a.png")).count() + }; + for background in [false, true] { + let (mut app, _) = app_with_three_moves(); + app.placing.off_the_ui_thread = background; + app.run("file.place.queue", json!({"files": [{"name": "a.png", "dataBase64": vectorcraft_format::base64_encode(&png)}]})).unwrap(); + assert_eq!(app.session.tool_id(), "place"); + recording_with(&mut app, files::MAX_ACTION_STEPS); + app.ui.status.clear(); + let journal = app.session.journal.len(); + tool_drag(&mut app, (40.0, 40.0), (80.0, 80.0)); + if background { + assert_eq!(app.session.journal.len(), journal, "the release places nothing yet"); + assert_eq!(placed(&app), 0); + assert_ne!(app.ui.status, steps, "nothing is checked before the file is placed"); + app.placing.wait_for_reads(); + let ctx = egui::Context::default(); + app.begin_host_iteration(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.tick(ui.ctx())); + out.textures_delta.clear(); + assert!(app.placing.pending.is_empty(), "the frame after the read places the file"); + } + assert_eq!(app.session.journal.len(), journal + 1, "background {background}: the place journals one step"); + assert_eq!(app.session.journal.last().map(|(c, _)| c.as_str()), Some("file.place")); + assert_eq!(placed(&app), 1, "background {background}: the placed file stays"); + assert_eq!(app.ui.status, steps, "background {background}: the status bar names the limit once the file is placed"); + assert!(app.ui.recording.is_some(), "the recording goes on"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), files::MAX_ACTION_STEPS, "background {background}"); + } + } + /// A step that reaches the journal outside a command, as a panel's or a dialog's commit does, is /// checked on the app's next frame. A journal rewrite that makes a checked step larger is /// checked again, and a frame with no new step does not name the limit again. diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index 5d24167c99..a4f2cd52e4 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -1,18 +1,23 @@ //! Learn panel (Window › Learn, and Explore tutorials at the right end of the app bar): short //! hands-on tutorials grouped by skill ([`catalog::SKILLS`]). The For you tab holds the Get started //! section with the Quick skill guide card, which opens the Skills tab. The Skills tab lists each -//! skill with an empty progress ring and its count of tutorials; a skill lists its tutorials, and a +//! skill with a progress ring that fills as its tutorials are finished and the count of tutorials +//! finished of all; a skill lists its tutorials, each finished one with a check mark, and a //! tutorial previews its numbered steps. A tool's name in a step is a link that shows where the tool //! is ([`locate`]). Back goes up one level. Under the steps, View more opens the tutorial's practice -//! sample as a new untitled document; Finish tutorial then marks it finished, and Next Up shows the -//! next tutorial in the same skill. `learn.list` lists the tutorials, `learn.skills` shows the list -//! of skills, `learn.show` opens one tutorial, `learn.locateTool` shows where a tool is, -//! `learn.openSample`, `learn.finish` and `learn.next` run the three practice controls, and -//! `window.learn` shows or hides the panel. The Home screen's Learn section shows the same card -//! ([`guide_card`]) and the tutorials, which open the panel. +//! sample as a new untitled document; the check box before each step then marks it done, Finish +//! tutorial marks the tutorial finished with all its steps, and Next Up shows the next tutorial in +//! the same skill. The preferences keep the progress ([`progress`]); Reset progress under the +//! skills clears it. `learn.list` lists the tutorials with their progress, `learn.skills` shows the +//! list of skills, `learn.show` opens one tutorial, `learn.locateTool` shows where a tool is, +//! `learn.openSample`, `learn.markStep`, `learn.finish` and `learn.next` run the practice controls, +//! `learn.resetProgress` clears the progress, and `window.learn` shows or hides the panel. The Home +//! screen's Learn section shows the same card ([`guide_card`]) and the tutorials, which open the +//! panel. pub mod catalog; pub mod locate; +pub mod progress; pub mod samples; use egui::{Sense, Stroke, Ui, vec2}; @@ -41,12 +46,10 @@ pub struct View { pub tutorial: Option<&'static str>, } -/// The tutorials whose samples View more opened, and the tutorials finished, in the order they -/// were finished. Each id is listed once. +/// The tutorials whose samples View more opened while the app runs. Each id is listed once. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Practice { pub opened: Vec<&'static str>, - pub finished: Vec<&'static str>, } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -126,17 +129,26 @@ fn skills(app: &mut VectorcraftApp, ui: &mut Ui) { } crate::widgets::subheader(ui, skill.title); for tutorial in skill.tutorials { - if row(ui, tutorial.title, None).clicked() { + if row(ui, tutorial.title, Mark::Check(app.ui.learn_progress.is_finished(tutorial.id))).clicked() { app.ui.learn.tutorial = Some(tutorial.id); } } return; } for skill in catalog::SKILLS { - if row(ui, skill.title, Some(skill.tutorials.len())).clicked() { + let ring = Mark::Ring { done: app.ui.learn_progress.done(skill), total: skill.tutorials.len() }; + if row(ui, skill.title, ring).clicked() { app.ui.learn.skill = Some(skill.id); } } + if !app.ui.learn_progress.is_empty() { + ui.add_space(6.0); + if ui.button(tl!("Reset progress")).clicked() + && let Err(e) = app.run("learn.resetProgress", json!({})) + { + app.status(e); + } + } } /// The Back button above a skill's tutorials and a tutorial's steps; true when it was clicked. @@ -144,15 +156,26 @@ fn back(ui: &mut Ui) -> bool { crate::widgets::icon_button(ui, "chevron-left", tl!("Back"), false, 22.0).clicked() } -/// A tutorial's title and its numbered steps. The tool's name in a step is a blue link: a click, or -/// Enter or Space while it has the keyboard focus, shows where the tool is (`learn.locateTool`). -fn steps(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &catalog::Tutorial) { +/// A tutorial's title and its numbered steps, each with a check box that shows whether it is done. +/// The check boxes can be changed once the tutorial's sample is open and until it is finished +/// (`learn.markStep`). The tool's name in a step is a blue link: a click, or Enter or Space while it +/// has the keyboard focus, shows where the tool is (`learn.locateTool`). +fn steps(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &'static catalog::Tutorial) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tutorial.title).size(13.0).color(t.text_strong)); ui.add_space(4.0); + let markable = app.ui.learn_practice.opened.contains(&tutorial.id) && !app.ui.learn_progress.is_finished(tutorial.id); let mut locate = None; + let mut marked = None; for (i, step) in tutorial.steps.iter().enumerate() { + let mut done = app.ui.learn_progress.step_done(tutorial, i); ui.horizontal_top(|ui| { + let resp = ui.add_enabled(markable, egui::Checkbox::without_text(&mut done)); + let name = format!("{}. {}", i + 1, step.plain()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, markable, done, &name)); + if resp.changed() { + marked = Some((i, done)); + } ui.label(egui::RichText::new(format!("{}.", i + 1)).color(t.text_dim)); let (Some(tool), Some((before, name, after))) = (step.tool, step.tool_name()) else { ui.add(egui::Label::new(egui::RichText::new(step.text).color(t.text)).wrap()); @@ -173,6 +196,11 @@ fn steps(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &catalog::Tutorial) { }); ui.add_space(2.0); } + if let Some((step, complete)) = marked + && let Err(e) = app.run("learn.markStep", json!({ "tutorial": tutorial.id, "step": step, "complete": complete })) + { + app.status(e); + } if let Some(tool) = locate { if let Err(e) = app.run("learn.locateTool", json!({ "tool": tool })) { app.status(e); @@ -205,12 +233,11 @@ fn practice(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &'static catalog::T if ui.button(tl!("View more")).clicked() { run = Some(("learn.openSample", params.clone())); } - let done = &app.ui.learn_practice; - if done.opened.contains(&id) && !done.finished.contains(&id) && ui.button(tl!("Finish tutorial")).clicked() { + if app.ui.learn_practice.opened.contains(&id) && !app.ui.learn_progress.is_finished(id) && ui.button(tl!("Finish tutorial")).clicked() { run = Some(("learn.finish", params.clone())); } }); - if app.ui.learn_practice.finished.contains(&id) { + if app.ui.learn_progress.is_finished(id) { ui.add_space(6.0); match catalog::next(id) { Some(next) => { @@ -236,46 +263,112 @@ fn practice(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &'static catalog::T } } -/// A clickable row: `title`, and for a skill (`tutorials`) a progress ring before it and the -/// count of tutorials done of all after it. No progress is kept yet, so the ring is empty. -fn row(ui: &mut Ui, title: &str, tutorials: Option) -> egui::Response { +/// What a row shows before its title. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mark { + /// A skill: a progress ring that fills as its tutorials are finished, and after the title the + /// count of tutorials finished of all. + Ring { done: usize, total: usize }, + /// A tutorial: a check mark once it is finished (true). + Check(bool), +} + +/// The radius of a row's progress ring or check mark. +const MARK_RADIUS: f32 = 7.0; + +/// A clickable row: `title`, with `mark` before it. Screen readers get the count of a skill's +/// tutorials finished, or Finished for a finished tutorial, as the row's value. +fn row(ui: &mut Ui, title: &str, mark: Mark) -> egui::Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover); } + let painter = ui.painter().with_clip_rect(rect); let mut left = rect.left() + 6.0; let mut right = rect.right() - 6.0; let chevron = egui::Rect::from_center_size(egui::pos2(right - 6.0, rect.center().y), vec2(12.0, 12.0)); crate::icons::paint(ui, "chevron-right", chevron, t.text_dim); right = chevron.left() - 6.0; - if let Some(total) = tutorials { - ui.painter().circle_stroke(egui::pos2(left + 8.0, rect.center().y), 7.0, Stroke::new(2.0, t.border)); - left += 24.0; - let count = ui.painter().layout_no_wrap(format!("0/{total}"), egui::FontId::proportional(11.5), t.text_dim); - right -= count.size().x; - ui.painter().galley(egui::pos2(right, rect.center().y - count.size().y / 2.0), count, t.text_dim); - right -= 6.0; - } - let name = ui.painter().layout(title.to_string(), egui::FontId::proportional(12.5), t.text, f32::INFINITY); + let at = egui::pos2(left + 8.0, rect.center().y); + left += 24.0; + let value = match mark { + Mark::Ring { done, total } => { + ring(&painter, &t, at, done, total); + let count = painter.layout_no_wrap(format!("{done}/{total}"), egui::FontId::proportional(11.5), t.text_dim); + right -= count.size().x; + painter.galley(egui::pos2(right, rect.center().y - count.size().y / 2.0), count, t.text_dim); + right -= 6.0; + Some(format!("{done}/{total}")) + } + Mark::Check(finished) => { + check(&painter, &t, at, finished); + finished.then(|| tl!("Finished").to_string()) + } + }; + let name = painter.layout(title.to_string(), egui::FontId::proportional(12.5), t.text, f32::INFINITY); let clip = egui::Rect::from_min_max(egui::pos2(left, rect.top()), egui::pos2(right.max(left), rect.bottom())); - ui.painter().with_clip_rect(clip).galley(egui::pos2(left, rect.center().y - name.size().y / 2.0), name, t.text); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), title)); + painter.with_clip_rect(clip).galley(egui::pos2(left, rect.center().y - name.size().y / 2.0), name, t.text); + resp.widget_info(|| egui::WidgetInfo { + current_text_value: value.clone(), + ..egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), title) + }); resp.on_hover_cursor(egui::CursorIcon::PointingHand) } +/// A skill's progress ring at `center`: an outline, with the share `done` of `total` drawn over it +/// clockwise from the top. With no tutorials it stays empty. +fn ring(painter: &egui::Painter, t: &Tokens, center: egui::Pos2, done: usize, total: usize) { + painter.circle_stroke(center, MARK_RADIUS, Stroke::new(2.0, t.border)); + if done == 0 || total == 0 { + return; + } + let stroke = Stroke::new(2.0, t.accent); + if done >= total { + painter.circle_stroke(center, MARK_RADIUS, stroke); + return; + } + let sweep = std::f32::consts::TAU * done as f32 / total as f32; + // About one segment per 1/32 of a turn, and never fewer than two. + let segments = ((32.0 * done as f32 / total as f32).ceil() as usize).max(2); + let points = (0..=segments) + .map(|k| { + let angle = -std::f32::consts::FRAC_PI_2 + sweep * k as f32 / segments as f32; + center + MARK_RADIUS * vec2(angle.cos(), angle.sin()) + }) + .collect(); + painter.add(egui::Shape::line(points, stroke)); +} + +/// A tutorial's mark at `center`: an outline, filled with a check mark once it is finished. +fn check(painter: &egui::Painter, t: &Tokens, center: egui::Pos2, finished: bool) { + if !finished { + painter.circle_stroke(center, MARK_RADIUS, Stroke::new(1.0, t.border)); + return; + } + painter.circle_filled(center, MARK_RADIUS, t.accent); + let tick = vec![center + vec2(-3.5, 0.0), center + vec2(-1.0, 2.5), center + vec2(3.5, -2.5)]; + painter.add(egui::Shape::line(tick, Stroke::new(1.5, t.panel))); +} + /// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.skills`, `learn.show`, -/// `learn.locateTool`, `learn.openSample`, `learn.finish` and `learn.next`. +/// `learn.locateTool`, `learn.openSample`, `learn.markStep`, `learn.finish`, `learn.next` and +/// `learn.resetProgress`. pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { Some(match id { "window.learn" => toggle(app, p), - "learn.list" => Ok(list()), + "learn.list" => Ok(list(&app.ui.learn_progress)), "learn.skills" => skills_list(app), "learn.show" => open(app, p), "learn.locateTool" => locate::locate_tool(app, p), "learn.openSample" => open_sample(app, p), + "learn.markStep" => mark_step(app, p), "learn.finish" => finish(app, p), "learn.next" => next(app, p), + "learn.resetProgress" => { + app.ui.learn_progress = progress::Progress::default(); + Ok(list(&app.ui.learn_progress)) + } _ => return None, }) } @@ -300,11 +393,25 @@ fn toggle(app: &mut VectorcraftApp, p: &Value) -> Result { Ok(json!(visible(&app.ui))) } -/// `learn.list`: every skill with its tutorials and their step counts. -fn list() -> Value { +/// `learn.list`: every skill with its count of tutorials finished (`done`) of all (`total`), and its +/// tutorials with their step counts, whether each is finished, and the zero-based indexes of its +/// steps marked done (`completedSteps`). +fn list(progress: &progress::Progress) -> Value { let skills = catalog::SKILLS.iter().map(|s| { - let tutorials: Vec = s.tutorials.iter().map(|t| json!({"id": t.id, "title": t.title, "steps": t.steps.len()})).collect(); - json!({"id": s.id, "title": s.title, "tutorials": tutorials}) + let tutorials: Vec = s + .tutorials + .iter() + .map(|t| { + json!({ + "id": t.id, + "title": t.title, + "steps": t.steps.len(), + "finished": progress.is_finished(t.id), + "completedSteps": progress.completed_steps(t), + }) + }) + .collect(); + json!({"id": s.id, "title": s.title, "done": progress.done(s), "total": s.tutorials.len(), "tutorials": tutorials}) }); json!({ "skills": skills.collect::>() }) } @@ -314,7 +421,7 @@ fn list() -> Value { fn skills_list(app: &mut VectorcraftApp) -> Result { crate::panel_docking::reveal_panel(app, ID)?; app.ui.learn = View { tab: Tab::Skills, skill: None, tutorial: None }; - Ok(list()) + Ok(list(&app.ui.learn_progress)) } /// `learn.show {tutorial}`: show the panel on the Skills tab with that tutorial's steps → the @@ -357,19 +464,42 @@ fn open_sample(app: &mut VectorcraftApp, p: &Value) -> Result { Ok(json!({"tutorial": id, "index": opened["index"], "title": opened["title"]})) } -/// `learn.finish {tutorial}`: Finish tutorial. Mark a tutorial whose sample was opened finished → -/// `{tutorial, finished: true, next}`, where `next` is the id of the next tutorial in its skill, or -/// null after the skill's last one. Finishing it again keeps one entry. The Learn view stays as it is. +/// `learn.markStep {tutorial, step, complete?}`: a step's check box. Mark step `step` (zero-based) +/// of a tutorial whose sample was opened done (`complete` true or omitted) or not done (false) → +/// `{tutorial, step, complete}`. A finished tutorial keeps every step done, so marking one of its +/// steps is an error; so are a step the tutorial does not have and a `complete` that is not true or +/// false. An error changes nothing. +fn mark_step(app: &mut VectorcraftApp, p: &Value) -> Result { + let (_, tutorial) = tutorial_param(p)?; + let id = tutorial.id; + let last = tutorial.steps.len().saturating_sub(1); + let step = match p.get("step") { + None | Some(Value::Null) => return Err("missing `step` (see learn.show)".into()), + Some(v) => v.as_u64().and_then(|s| usize::try_from(s).ok()).filter(|s| *s < tutorial.steps.len()), + }; + let step = step.ok_or_else(|| format!("`step` must be a step number from 0 to {last}"))?; + let complete = crate::menus::opt_bool(p, "complete")?.unwrap_or(true); + if !app.ui.learn_practice.opened.contains(&id) { + return Err(format!("open the practice sample of `{id}` first (see learn.openSample)")); + } + if app.ui.learn_progress.is_finished(id) { + return Err(format!("`{id}` is finished (see learn.resetProgress)")); + } + app.ui.learn_progress.mark(id, step, complete); + Ok(json!({"tutorial": id, "step": step, "complete": complete})) +} + +/// `learn.finish {tutorial}`: Finish tutorial. Mark a tutorial whose sample was opened finished, +/// with all its steps done → `{tutorial, finished: true, next}`, where `next` is the id of the next +/// tutorial in its skill, or null after the skill's last one. Finishing it again keeps one entry, +/// also after a restart, when its sample has not been opened again. The Learn view stays as it is. fn finish(app: &mut VectorcraftApp, p: &Value) -> Result { let (_, tutorial) = tutorial_param(p)?; let id = tutorial.id; - let practice = &mut app.ui.learn_practice; - if !practice.opened.contains(&id) { + if !app.ui.learn_practice.opened.contains(&id) && !app.ui.learn_progress.is_finished(id) { return Err(format!("open the practice sample of `{id}` first (see learn.openSample)")); } - if !practice.finished.contains(&id) { - practice.finished.push(id); - } + app.ui.learn_progress.finish(id); Ok(json!({"tutorial": id, "finished": true, "next": catalog::next(id).map(|t| t.id)})) } @@ -379,7 +509,7 @@ fn finish(app: &mut VectorcraftApp, p: &Value) -> Result { fn next(app: &mut VectorcraftApp, p: &Value) -> Result { let (skill, tutorial) = tutorial_param(p)?; let id = tutorial.id; - if !app.ui.learn_practice.finished.contains(&id) { + if !app.ui.learn_progress.is_finished(id) { return Err(format!("finish `{id}` first (see learn.finish)")); } let after = catalog::next(id).ok_or_else(|| format!("`{id}` is the last tutorial in its skill"))?; diff --git a/crates/ui-egui/src/panels/learn/progress.rs b/crates/ui-egui/src/panels/learn/progress.rs new file mode 100644 index 0000000000..a425b0e58f --- /dev/null +++ b/crates/ui-egui/src/panels/learn/progress.rs @@ -0,0 +1,122 @@ +//! What a learner has done in the Learn panel: the steps marked done in each tutorial and the +//! tutorials finished. The UI preferences keep it (`UiState::learn_progress`), so it lasts from +//! one start to the next where the preferences are saved. Saved progress is read leniently: an +//! unknown tutorial id, a step the tutorial does not have, a repeated entry or a value of the wrong +//! kind is skipped, and the rest of the preferences read as usual. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +use super::catalog; + +/// The most entries read from one saved list or map; the rest are skipped. +const MAX_READ: usize = 1024; + +/// The tutorials finished and the steps marked done. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Progress { + /// The tutorials finished, in the order they were finished. Each id is listed once. + pub finished: Vec<&'static str>, + /// For each tutorial not finished, the zero-based indexes of the steps marked done, ascending. + /// A tutorial with no step marked has no entry. + pub steps: BTreeMap<&'static str, Vec>, +} + +impl Progress { + /// Nothing is finished and no step is marked. + pub fn is_empty(&self) -> bool { + self.finished.is_empty() && self.steps.is_empty() + } + + /// Whether the tutorial `id` is finished. + pub fn is_finished(&self, id: &str) -> bool { + self.finished.contains(&id) + } + + /// The zero-based indexes of `tutorial`'s steps marked done, ascending: every step of a + /// finished tutorial. + pub fn completed_steps(&self, tutorial: &catalog::Tutorial) -> Vec { + if self.is_finished(tutorial.id) { + return (0..tutorial.steps.len()).collect(); + } + self.steps.get(tutorial.id).cloned().unwrap_or_default() + } + + /// Whether step `step` of `tutorial` is marked done. + pub fn step_done(&self, tutorial: &catalog::Tutorial, step: usize) -> bool { + self.is_finished(tutorial.id) || self.steps.get(tutorial.id).is_some_and(|s| s.contains(&step)) + } + + /// Mark step `step` of the unfinished tutorial `id` done (`complete`) or not done. + pub fn mark(&mut self, id: &'static str, step: usize, complete: bool) { + let marks = self.steps.entry(id).or_default(); + match (marks.binary_search(&step), complete) { + (Err(at), true) => marks.insert(at, step), + (Ok(at), false) => { + marks.remove(at); + } + _ => {} + } + if marks.is_empty() { + self.steps.remove(id); + } + } + + /// Mark the tutorial `id` finished, with all its steps done. Finishing it again keeps one entry. + pub fn finish(&mut self, id: &'static str) { + if !self.is_finished(id) { + self.finished.push(id); + } + self.steps.remove(id); + } + + /// How many of `skill`'s tutorials are finished. + pub fn done(&self, skill: &catalog::Skill) -> usize { + skill.tutorials.iter().filter(|t| self.is_finished(t.id)).count() + } + + /// Progress read from a saved value, keeping only what the catalog knows (see the module). + fn from_value(saved: &Value) -> Self { + let mut progress = Progress::default(); + let known = |id: &Value| id.as_str().and_then(catalog::tutorial).map(|(_, t)| t); + if let Some(finished) = saved.get("finished").and_then(Value::as_array) { + for tutorial in finished.iter().take(MAX_READ).filter_map(known) { + progress.finish(tutorial.id); + } + } + if let Some(steps) = saved.get("steps").and_then(Value::as_object) { + for (id, marks) in steps.iter().take(MAX_READ) { + let Some((_, tutorial)) = catalog::tutorial(id) else { continue }; + if progress.is_finished(tutorial.id) { + continue; + } + let indexes = marks.as_array().into_iter().flatten().take(MAX_READ).filter_map(Value::as_u64); + for step in indexes.filter_map(|s| usize::try_from(s).ok()).filter(|s| *s < tutorial.steps.len()) { + progress.mark(tutorial.id, step, true); + } + } + } + progress + } +} + +/// The saved form: `{finished: [id], steps: {id: [index]}}`. +#[derive(Serialize)] +struct Saved<'a> { + finished: &'a [&'static str], + steps: &'a BTreeMap<&'static str, Vec>, +} + +impl Serialize for Progress { + fn serialize(&self, serializer: S) -> Result { + Saved { finished: &self.finished, steps: &self.steps }.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Progress { + fn deserialize>(deserializer: D) -> Result { + Ok(Progress::from_value(&Value::deserialize(deserializer)?)) + } +} diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs index 4bd00648d1..ea65a64fe9 100644 --- a/crates/ui-egui/src/panels/learn/tests.rs +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; -use egui::accesskit::Node; -use serde_json::json; +use egui::accesskit::{Node, Role, Toggled}; +use serde_json::{Value, json}; use vectorcraft_engine::Session; use super::{ID, Practice, Tab, View, catalog, samples, show}; @@ -225,7 +225,8 @@ fn learn_show_opens_a_tutorial_and_learn_list_lists_them() { assert_eq!(skills.len(), catalog::SKILLS.len()); let count: usize = skills.iter().map(|s| s["tutorials"].as_array().map_or(0, Vec::len)).sum(); assert_eq!(count, catalog::SKILLS.iter().map(|s| s.tutorials.len()).sum::()); - assert_eq!(skills[2]["tutorials"][1], json!({"id": "gradient", "title": "Add a gradient", "steps": 3})); + assert_eq!(skills[2]["tutorials"][1], json!({"id": "gradient", "title": "Add a gradient", "steps": 3, "finished": false, "completedSteps": []})); + assert_eq!((skills[2]["done"].clone(), skills[2]["total"].clone()), (json!(0), json!(3))); } /// Every tutorial has one sample of its own, in the catalog's order, each one small and read @@ -279,7 +280,8 @@ fn view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document() { assert_eq!(docs[0].doc.node_count(), work, "the open document stays as it was"); assert!(docs[1].path.is_none() && docs[1].title().starts_with("Untitled-"), "{}", docs[1].title()); let pen = docs[1].doc.node_count(); - assert_eq!(app.ui.learn_practice, Practice { opened: vec!["pen-paths"], finished: vec![] }); + assert_eq!(app.ui.learn_practice, Practice { opened: vec!["pen-paths"] }); + assert!(app.ui.learn_progress.is_empty(), "opening a sample marks nothing"); let r = app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); assert_eq!(r["tutorial"], "gradient"); @@ -306,7 +308,7 @@ fn finishing_a_tutorial_offers_next_up_in_its_skill() { let text = painted_text(&mut app, show); assert!(text.contains("Finish tutorial") && !text.contains("Next Up"), "{text}"); click(&mut app, &ctx, "Finish tutorial", show); - assert_eq!(app.ui.learn_practice.finished, ["pen-paths"]); + assert_eq!(app.ui.learn_progress.finished, ["pen-paths"]); let text = painted_text(&mut app, show); assert!(text.contains("Next Up") && text.contains("Draw smooth curves") && !text.contains("Finish tutorial"), "{text}"); let docs = app.session.documents().len(); @@ -350,7 +352,7 @@ fn the_practice_controls_work_from_the_keyboard() { assert_eq!(app.ui.learn_practice.opened, ["pen-paths"]); tab_to(&mut app, &ctx, "Finish tutorial", show); key(&mut app, &ctx, egui::Key::Enter, show); - assert_eq!(app.ui.learn_practice.finished, ["pen-paths"]); + assert_eq!(app.ui.learn_progress.finished, ["pen-paths"]); tab_to(&mut app, &ctx, "Next Up", show); key(&mut app, &ctx, egui::Key::Enter, show); assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("smooth-curves") }); @@ -408,13 +410,14 @@ fn learn_finish_and_learn_next_follow_the_practice() { assert_eq!(app.run("learn.finish", json!({"tutorial": "gradient"})).unwrap_err(), open); assert_eq!(app.run("learn.next", json!({"tutorial": "gradient"})).unwrap_err(), "finish `gradient` first (see learn.finish)"); assert_eq!(app.ui.learn_practice, Practice::default()); + assert!(app.ui.learn_progress.is_empty()); app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); assert_eq!(app.run("learn.next", json!({"tutorial": "gradient"})).unwrap_err(), "finish `gradient` first (see learn.finish)"); for _ in 0..2 { let r = app.run("learn.finish", json!({"tutorial": "gradient"})).unwrap(); assert_eq!(r, json!({"tutorial": "gradient", "finished": true, "next": "copy-colors"})); } - assert_eq!(app.ui.learn_practice.finished, ["gradient"], "finishing again keeps one entry"); + assert_eq!(app.ui.learn_progress.finished, ["gradient"], "finishing again keeps one entry"); let r = app.run("learn.next", json!({"tutorial": "gradient"})).unwrap(); assert_eq!(r, json!({"skill": "color", "tutorial": "copy-colors", "title": "Copy colors from other art"})); assert!(shown(&app)); @@ -471,6 +474,7 @@ fn the_practice_commands_reject_a_missing_or_unknown_tutorial() { assert_eq!(app.run(cmd, params).unwrap_err(), error, "{cmd}"); assert_eq!(app.ui.learn, View::default()); assert_eq!(app.ui.learn_practice, Practice::default()); + assert!(app.ui.learn_progress.is_empty()); assert_eq!(app.session.documents().len(), 1); assert!(!shown(&app)); } @@ -581,3 +585,274 @@ fn learn_locate_tool_is_unavailable_while_the_toolbars_are_hidden() { assert_eq!(reply["location"], "unavailable", "{reply}"); assert!(app.ui.learn_locate.is_none() && !app.ui.toolbar && app.ui.tab_hid.is_some(), "Tab's state stays"); } + +/// The screen-reader name of step `i` of the Pen tutorial's check box. +fn pen_step(i: usize) -> String { + let (_, tutorial) = catalog::tutorial("pen-paths").unwrap(); + format!("{}. {}", i + 1, tutorial.steps[i].plain()) +} + +/// The check boxes of what `draw` draws, from the top, as (enabled, checked). +fn check_boxes(app: &mut VectorcraftApp, ctx: &egui::Context, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) -> Vec<(bool, bool)> { + let nodes = frame(app, ctx, vec![], draw); + let mut boxes: Vec<&Node> = nodes.iter().filter(|n| n.role() == Role::CheckBox).collect(); + boxes.sort_by(|a, b| a.bounds().map_or(0.0, |r| r.y0).total_cmp(&b.bounds().map_or(0.0, |r| r.y0))); + boxes.iter().map(|n| (!n.is_disabled(), n.toggled() == Some(Toggled::True))).collect() +} + +/// Each step has a check box, off until the tutorial's sample is open. A click marks only its own +/// step and a second click clears it; marking every step does not finish the tutorial. A finished +/// tutorial shows every step done, and its check boxes and `learn.markStep` no longer change them. +#[test] +fn a_step_s_check_box_marks_only_that_step() { + let mut app = app(); + let ctx = context(); + assert_eq!(check_boxes(&mut app, &ctx, pen_paths), [(false, false); 3], "off before View more"); + click(&mut app, &ctx, &pen_step(1), pen_paths); + assert!(app.ui.learn_progress.is_empty(), "a check box that is off changes nothing"); + app.run("learn.openSample", json!({"tutorial": "pen-paths"})).unwrap(); + click(&mut app, &ctx, &pen_step(1), pen_paths); + assert_eq!(check_boxes(&mut app, &ctx, pen_paths), [(true, false), (true, true), (true, false)], "{}", app.ui.status); + let list = app.run("learn.list", json!({})).unwrap(); + assert_eq!(list["skills"][1]["tutorials"][0]["completedSteps"], json!([1])); + assert_eq!(list["skills"][1]["tutorials"][1]["completedSteps"], json!([]), "other tutorials stay as they are"); + click(&mut app, &ctx, &pen_step(1), pen_paths); + assert!(app.ui.learn_progress.is_empty(), "a second click clears the mark"); + for i in 0..3 { + click(&mut app, &ctx, &pen_step(i), pen_paths); + } + assert_eq!(check_boxes(&mut app, &ctx, pen_paths), [(true, true); 3]); + assert!(!app.ui.learn_progress.is_finished("pen-paths"), "marking every step does not finish the tutorial"); + assert!(painted_text(&mut app, pen_paths).contains("Finish tutorial")); + click(&mut app, &ctx, &pen_step(0), pen_paths); + click(&mut app, &ctx, "Finish tutorial", pen_paths); + assert_eq!(app.ui.learn_progress.finished, ["pen-paths"]); + assert!(app.ui.learn_progress.steps.is_empty(), "a finished tutorial keeps no marks of its own"); + assert_eq!(check_boxes(&mut app, &ctx, pen_paths), [(false, true); 3], "Finish marks every step"); + let before = app.ui.learn_progress.clone(); + click(&mut app, &ctx, &pen_step(2), pen_paths); + let error = app.run("learn.markStep", json!({"tutorial": "pen-paths", "step": 2, "complete": false})).unwrap_err(); + assert_eq!(error, "`pen-paths` is finished (see learn.resetProgress)"); + assert_eq!(app.ui.learn_progress, before); +} + +/// The check boxes are reached with Tab and changed with Space. +#[test] +fn a_step_s_check_box_works_from_the_keyboard() { + let mut app = app(); + let ctx = context(); + app.run("learn.openSample", json!({"tutorial": "pen-paths"})).unwrap(); + frame(&mut app, &ctx, vec![], pen_paths); + tab_to(&mut app, &ctx, &pen_step(2), pen_paths); + key(&mut app, &ctx, egui::Key::Space, pen_paths); + assert_eq!(app.ui.learn_progress.steps.get("pen-paths"), Some(&vec![2]), "{}", app.ui.status); +} + +/// Finishing tutorials fills only their own skill's ring and count, once each however often they +/// are finished; the skill's tutorial rows show which are finished; `learn.list` answers the same. +#[test] +fn finishing_tutorials_fills_their_skill_s_ring_and_count() { + let mut app = app(); + let ctx = context(); + for id in ["pen-paths", "smooth-curves"] { + app.run("learn.openSample", json!({"tutorial": id})).unwrap(); + for _ in 0..2 { + app.run("learn.finish", json!({"tutorial": id})).unwrap(); + } + } + let values = |app: &mut VectorcraftApp, view: View| { + app.ui.learn = view; + let nodes = frame(app, &ctx, vec![], show); + nodes + .iter() + .filter(|n| n.role() == Role::Button) + .map(|n| (n.label().unwrap_or_default().to_string(), n.value().map(str::to_string))) + .collect::>() + }; + let skills = View { tab: Tab::Skills, skill: None, tutorial: None }; + let rows = values(&mut app, skills.clone()); + for skill in catalog::SKILLS { + let count = if skill.id == "draw" { "2/3".to_string() } else { format!("0/{}", skill.tutorials.len()) }; + assert!(rows.contains(&(skill.title.to_string(), Some(count.clone()))), "{} {count} in {rows:?}", skill.id); + } + assert!(painted_text(&mut app, show).contains("2/3")); + let list = app.run("learn.list", json!({})).unwrap(); + let draw = &list["skills"][1]; + assert_eq!((draw["done"].clone(), draw["total"].clone()), (json!(2), json!(3))); + assert_eq!(draw["tutorials"][1]["finished"], json!(true)); + assert_eq!(draw["tutorials"][1]["completedSteps"], json!([0, 1, 2])); + assert_eq!(list["skills"][2]["done"], json!(0), "other skills stay at 0"); + + let rows = values(&mut app, View { tab: Tab::Skills, skill: Some("draw"), tutorial: None }); + assert!(rows.contains(&("Draw paths with the Pen".to_string(), Some("Finished".to_string()))), "{rows:?}"); + assert!(rows.contains(&("Draw smooth curves".to_string(), Some("Finished".to_string()))), "{rows:?}"); + assert!(rows.contains(&("Sketch with the Pencil".to_string(), None)), "{rows:?}"); + + app.run("learn.openSample", json!({"tutorial": "pencil-sketch"})).unwrap(); + app.run("learn.finish", json!({"tutorial": "pencil-sketch"})).unwrap(); + let rows = values(&mut app, skills); + assert!(rows.contains(&("Draw and build".to_string(), Some("3/3".to_string()))), "every tutorial finished: {rows:?}"); +} + +/// A skill row with no tutorials, which the catalog does not have today, shows an empty ring and +/// 0/0. +fn empty_skill(_: &mut VectorcraftApp, ui: &mut egui::Ui) { + super::row(ui, "Empty skill", super::Mark::Ring { done: 0, total: 0 }); +} + +#[test] +fn a_skill_with_no_tutorials_shows_0_of_0() { + let mut app = app(); + let text = painted_text(&mut app, empty_skill); + assert!(text.contains("Empty skill") && text.contains("0/0"), "{text}"); +} + +/// `learn.markStep` marks a step done by default and with `complete: true`, clears it with `false`, +/// and answers what it did; a bad param is an error and changes nothing. +#[test] +fn learn_mark_step_checks_its_params() { + let mut app = app(); + let open = "open the practice sample of `gradient` first (see learn.openSample)"; + assert_eq!(app.run("learn.markStep", json!({"tutorial": "gradient", "step": 0})).unwrap_err(), open); + assert!(app.ui.learn_progress.is_empty()); + app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!( + app.run("learn.markStep", json!({"tutorial": "gradient", "step": 0})).unwrap(), + json!({"tutorial": "gradient", "step": 0, "complete": true}) + ); + let before = app.ui.learn_progress.clone(); + let missing = "missing `step` (see learn.show)"; + let range = "`step` must be a step number from 0 to 2"; + for (params, error) in [ + (json!({"tutorial": "gradient"}), missing), + (json!({"tutorial": "gradient", "step": null}), missing), + (json!({"tutorial": "gradient", "step": 3}), range), + (json!({"tutorial": "gradient", "step": -1}), range), + (json!({"tutorial": "gradient", "step": 1.5}), range), + (json!({"tutorial": "gradient", "step": "1"}), range), + (json!({"tutorial": "gradient", "step": u64::MAX}), range), + (json!({"tutorial": "gradient", "step": 1, "complete": "yes"}), "`complete` must be true or false"), + (json!({"tutorial": "nope", "step": 1}), "unknown tutorial `nope` (see learn.list)"), + (json!({"step": 1}), "missing `tutorial` (see learn.list)"), + ] { + assert_eq!(app.run("learn.markStep", params.clone()).unwrap_err(), error, "{params}"); + assert_eq!(app.ui.learn_progress, before, "{params}"); + } + let r = app.run("learn.markStep", json!({"tutorial": "gradient", "step": 2, "complete": null})).unwrap(); + assert_eq!(r, json!({"tutorial": "gradient", "step": 2, "complete": true})); + let r = app.run("learn.markStep", json!({"tutorial": "gradient", "step": 0, "complete": false})).unwrap(); + assert_eq!(r, json!({"tutorial": "gradient", "step": 0, "complete": false})); + let r = app.run("learn.markStep", json!({"tutorial": "gradient", "step": 0, "complete": false})).unwrap(); + assert_eq!(r["complete"], json!(false), "clearing a step that is not marked is no error"); + assert_eq!(app.ui.learn_progress.steps.get("gradient"), Some(&vec![2])); + assert!(app.ui.learn_progress.finished.is_empty()); +} + +/// `learn.resetProgress` and Reset progress clear every tutorial finished and every step marked; +/// documents, opened samples and the Learn view stay. Reset progress shows only when there is +/// progress. +#[test] +fn reset_progress_clears_only_the_progress() { + let mut app = app(); + let ctx = context(); + let skills = View { tab: Tab::Skills, skill: None, tutorial: None }; + app.ui.learn = skills.clone(); + assert!(!painted_text(&mut app, show).contains("Reset progress")); + app.run("learn.openSample", json!({"tutorial": "pen-paths"})).unwrap(); + app.run("learn.finish", json!({"tutorial": "pen-paths"})).unwrap(); + app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); + app.run("learn.markStep", json!({"tutorial": "gradient", "step": 1})).unwrap(); + let view = app.ui.learn.clone(); + let docs = app.session.documents().len(); + let reply = app.run("learn.resetProgress", json!({})).unwrap(); + assert!(app.ui.learn_progress.is_empty()); + assert_eq!(reply, app.run("learn.list", json!({})).unwrap()); + assert_eq!(reply["skills"][1]["done"], json!(0)); + assert_eq!((app.ui.learn.clone(), app.session.documents().len()), (view, docs)); + assert_eq!(app.ui.learn_practice.opened, ["pen-paths", "gradient"], "opened samples stay open for practice"); + + app.run("learn.finish", json!({"tutorial": "gradient"})).unwrap(); + app.ui.learn = skills.clone(); + assert!(painted_text(&mut app, show).contains("Reset progress")); + click(&mut app, &ctx, "Reset progress", show); + assert!(app.ui.learn_progress.is_empty(), "{}", app.ui.status); + assert_eq!(app.ui.learn, skills); + assert!(!painted_text(&mut app, show).contains("Reset progress")); +} + +/// The UI state after its preferences are saved and read again, as at the next start. +fn restarted(app: &VectorcraftApp) -> crate::state::UiState { + let bytes = serde_json::to_vec(&app.ui).unwrap(); + crate::state::UiState::read_preferences(&bytes).expect("the preferences read").ui.sanitized() +} + +/// The preferences keep the tutorials finished and the steps marked; the opened samples, the +/// Learn view and a tool's mark last only while the app runs, so a step can be marked again only +/// after View more. +#[test] +fn the_preferences_keep_the_progress() { + let mut app = app(); + app.run("learn.openSample", json!({"tutorial": "pen-paths"})).unwrap(); + app.run("learn.finish", json!({"tutorial": "pen-paths"})).unwrap(); + app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); + for step in [2, 0] { + app.run("learn.markStep", json!({"tutorial": "gradient", "step": step})).unwrap(); + } + let list = app.run("learn.list", json!({})).unwrap(); + let ui = restarted(&app); + assert_eq!(ui.learn_progress, app.ui.learn_progress); + assert_eq!(ui.learn_progress.steps.get("gradient"), Some(&vec![0, 2])); + assert_eq!((ui.learn_practice.clone(), ui.learn.clone(), ui.learn_locate.is_none()), (Practice::default(), View::default(), true)); + + let mut next = self::app(); + next.ui = ui; + assert_eq!(next.run("learn.list", json!({})).unwrap(), list); + assert_eq!(next.run("learn.next", json!({"tutorial": "pen-paths"})).unwrap()["tutorial"], "smooth-curves"); + let again = next.run("learn.finish", json!({"tutorial": "pen-paths"})).unwrap(); + assert_eq!(again, json!({"tutorial": "pen-paths", "finished": true, "next": "smooth-curves"}), "finishing again needs no sample"); + assert_eq!(next.ui.learn_progress.finished, ["pen-paths"]); + let open = "open the practice sample of `gradient` first (see learn.openSample)"; + assert_eq!(next.run("learn.markStep", json!({"tutorial": "gradient", "step": 1})).unwrap_err(), open); +} + +/// Saved preferences with `learn_progress` set to `progress` (left out for None), and +/// `control_bar` on, read as at a start. +fn read_progress(progress: Option) -> crate::state::UiState { + let mut saved = serde_json::to_value(crate::state::UiState::default()).unwrap(); + let fields = saved.as_object_mut().unwrap(); + fields.insert("control_bar".into(), json!(true)); + match progress { + Some(p) => fields.insert("learn_progress".into(), p), + None => fields.remove("learn_progress"), + }; + let ui = crate::state::UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).expect("the preferences read").ui; + assert!(ui.control_bar, "the other preferences read as usual"); + ui +} + +/// Preferences saved before the progress was kept read with none. Saved progress keeps only the +/// catalog's tutorials and steps, each once, and a value of the wrong kind, or a huge list, does not +/// stop the rest of the preferences from reading. +#[test] +fn saved_progress_is_read_leniently() { + assert!(read_progress(None).learn_progress.is_empty()); + for bad in [json!("garbage"), json!(3), json!(null), json!([1, 2]), json!({"finished": "pen-paths", "steps": [0]})] { + assert!(read_progress(Some(bad.clone())).learn_progress.is_empty(), "{bad}"); + } + let ui = read_progress(Some(json!({ + "finished": ["pen-paths", "nope", 3, "pen-paths", "gradient"], + "steps": { + "gradient": [0], + "smooth-curves": [2, 0, 2, 99, -1, "1", 1.5], + "nope": [0], + "copy-colors": "x", + "add-headline": [], + }, + }))); + assert_eq!(ui.learn_progress.finished, ["pen-paths", "gradient"]); + let steps: Vec<(&str, Vec)> = ui.learn_progress.steps.iter().map(|(id, s)| (*id, s.clone())).collect(); + assert_eq!(steps, [("smooth-curves", vec![0, 2])], "a finished tutorial keeps no marks"); + let ui = read_progress(Some(json!({"finished": vec!["pen-paths"; 100_000], "steps": {"smooth-curves": vec![1; 100_000]}}))); + assert_eq!(ui.learn_progress.finished, ["pen-paths"]); + assert_eq!(ui.learn_progress.steps.get("smooth-curves"), Some(&vec![1])); +} diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 2fb130ae3e..f9d9b524c9 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -360,8 +360,8 @@ fn badge(ui: &Ui, r: egui::Rect, status: &str) { } /// The tooltip of row `r`. A row outside the layer tree also names where it is stored -/// (`links.list`'s `owner`): the symbol, graph design or pattern whose art holds it, and the object -/// whose opacity mask holds it, by `mask` (that object's name) or else by its id. +/// (`links.list`'s `owner`): the symbol, graph design, pattern or brush whose art holds it, and the +/// object whose opacity mask holds it, by `mask` (that object's name) or else by its id. pub(super) fn status_tip(r: &Value, mask: Option) -> String { let tip = file_tip(r); let owner = &r["owner"]; @@ -370,6 +370,7 @@ pub(super) fn status_tip(r: &Value, mask: Option) -> String { Some("symbol") => Some(tl!("Symbols")), Some("graphDesign") => Some(tl!("Graph Design")), Some("pattern") => Some(tl!("Pattern")), + Some("brush") => Some(tl!("Brushes")), _ => None, }; let tip = match art { diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 8055c1288c..6b773e9d3b 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -189,6 +189,7 @@ fn a_nested_rows_tooltip_names_what_holds_it() { assert_eq!(tip(json!({"kind": "symbol", "name": "Mark"}), None), "/x/a.png\nSymbols: Mark"); assert_eq!(tip(json!({"kind": "pattern", "name": "Dots"}), None), "/x/a.png\nPattern: Dots"); assert_eq!(tip(json!({"kind": "graphDesign", "name": "Bar"}), None), "/x/a.png\nGraph Design: Bar"); + assert_eq!(tip(json!({"kind": "brush", "name": "Leaf", "index": 2}), None), "/x/a.png\nBrushes: Leaf"); assert_eq!(tip(json!({"kind": "layers", "maskOf": 7}), Some("")), "/x/a.png\nOpacity Mask: "); assert_eq!(tip(json!({"kind": "symbol", "name": "Mark", "maskOf": 7}), None), "/x/a.png\nSymbols: Mark\nOpacity Mask: 7"); // A row in the layers says only its file. diff --git a/crates/ui-egui/src/placing.rs b/crates/ui-egui/src/placing.rs index 11ae770d5d..a627321d76 100644 --- a/crates/ui-egui/src/placing.rs +++ b/crates/ui-egui/src/placing.rs @@ -178,6 +178,23 @@ pub struct Placing { pub(crate) reading: std::sync::Arc<[std::sync::atomic::AtomicUsize; 2]>, } +impl Placing { + /// Unit tests: wait until the reader thread has finished every read started so far. The thread + /// runs its jobs in order, so a job sent after them runs once they have handed back their files. + #[cfg(test)] + pub(crate) fn wait_for_reads(&self) { + if let Some(reader) = &self.reader { + let (tx, rx) = channel(); + let marker: Job = Box::new(move || { + let _ = tx.send(()); + }); + if reader.send(marker).is_ok() { + let _ = rx.recv(); + } + } + } +} + struct PendingSwatch { clipboard: Clipboard, insertion: Insertion, diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 4e02180017..26606c2763 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -765,12 +765,10 @@ fn chosen_path(items: &[(String, Value)]) -> Option<&str> { items.iter().find(|(_, p)| is_bare(p)).or(items.first()).map(|(path, _)| path.as_str()) } -/// Does a command's params doc (`{a?, b?} …`) say it runs with no params, as a shortcut runs it? +/// Does a command's params doc (`{a?, b?: bool} …`) say it runs with no params, as a shortcut runs +/// it? It does when it starts with `{` and [`names_a_required_field`] finds no field a call must give. fn runs_without_params(params: &str) -> bool { - params - .strip_prefix('{') - .and_then(|r| r.split_once('}')) - .is_some_and(|(fields, _)| fields.split(',').all(|f| f.trim().is_empty() || f.trim_end().ends_with('?'))) + params.starts_with('{') && !names_a_required_field(params) } /// Does the editor list `e` under `overrides`? An entry that [`needs_a_value`] is listed only while @@ -783,8 +781,13 @@ fn listed(overrides: &Overrides, e: &Entry) -> bool { /// with none, and that no item of the menu bar runs: Transparency and Opacity Mask Options need /// a value to set, Align and Distribute a direction, Delete Swatch and Move Swatch a swatch name, /// and the library, data set, text style and SVG filter loaders a file, which their menu items ask -/// for. -const NEEDS_A_PARAM: [&str; 20] = [ +/// for. The commands after them have no menu path: the setters of panels and dialogs (Fill, Stroke +/// Options, Character, Set Bounds), the exports and saves that give the file back without a `path` +/// (Save Document gives the file back when the document has no file yet; File › Save takes a key +/// instead), the undo group's begin and end, and the commands that need a point, a value or a name +/// (Create Dimension, Edit Freeform Point, Select by Color, Move Brush). New Symbol is left out +/// because the Symbols panel runs it through the Symbol Options dialog. +const NEEDS_A_PARAM: [&str; 74] = [ "transparency.set", "transparency.setOpacityMask", "stroke.set", @@ -805,6 +808,60 @@ const NEEDS_A_PARAM: [&str; 20] = [ "paraStyle.load", "textStyle.loadAll", "svgFilter.import", + "addon.pathtools.setAnchor", + "addon.shapes.configure", + "appearance.addEffect", + "artboard.align", + "artboard.distribute", + "assets.export", + "assets.export3D", + "assets.settings.set", + "attributes.set", + "brush.move", + "css.export", + "dimension.create", + "document.export", + "document.exportDxf", + "document.exportEps", + "document.exportForWeb", + "document.exportPdf", + "document.save", + "document.setup", + "edit.undoGroup.begin", + "edit.undoGroup.end", + "eyedropper.setOptions", + "file.package", + "library.createGroup", + "links.placementOptions", + "links.unembed", + "livePaint.gapOptions", + "object.blend.createSpine", + "object.blend.setEasing", + "object.intertwine.arrange", + "object.objectsOnPath.options", + "object.puppetWarp.pins", + "object.setBounds", + "object.setLiveShape", + "object.setOverprint", + "object.setProps", + "object.slice.select", + "paint.freeform.setPoint", + "paint.setFill", + "paint.setGradientGeom", + "paint.setStroke", + "perspective.grid.define", + "perspective.grid.set", + "perspective.plane.move", + "print.setup", + "select.color", + "select.globalEditOptions", + "stroke.setAdvanced", + "symbol.new", + "symbol.options", + "text.setFormat", + "text.setRangeStyle", + "text.setStyle", + "type.ruby", ]; /// The (label, params) of each menu bar item, by command id. @@ -907,7 +964,7 @@ fn a_menu_bar_item_runs(id: &str, params: &str) -> bool { /// Is engine command `c` listed in Edit › Keyboard Shortcuts whether or not an item of the menu bar /// runs it? A command with a menu path of its own is. One without (a panel menu item) is when a key /// alone can run it: it has a default key, or it is an undoable command, not an alias, whose params -/// doc ends each field with `?`. +/// doc names no field a call must give ([`runs_without_params`]). fn listed_on_its_own(c: &vectorcraft_engine::CommandSpec) -> bool { !c.menu.is_empty() || c.shortcut.is_some() || (c.journal && !vectorcraft_engine::cmd::is_alias(c.id) && runs_without_params(c.params)) } @@ -2309,11 +2366,39 @@ mod tests { for id in ["brush.duplicate", "path.convertAnchors", "document.json", "prefs.list"] { assert!(entry(id).is_none(), "{id}"); } - for (doc, bare) in - [("{}", true), ("{dx?, dy?} x", true), ("{id?|ids?, clip?}", true), ("{name, newName?}", false), ("{a?: {b}}", false), ("same as", false)] - { + // The Symbols panel runs New Symbol through Symbol Options, so it waits for a key the user gives it. + assert!(entry("symbol.new").is_some_and(|e| !listed(&Overrides::new(), &e))); + for (doc, bare) in [ + ("{}", true), + ("{dx?, dy?} x", true), + ("{id?|ids?, clip?}", true), + ("{name, newName?}", false), + ("{a?: {b}}", true), + ("{ids?|id?, on?: bool} turn it on or off", true), + ("{ids?: [id…] (the selection), anchors?: [index…]}", true), + ("{format?: a|b, size?: 1|3|5}", true), + ("{artboard?: index=0}", true), + ("{mode: a|b, on?: bool}", false), + ("same as", false), + ] { assert_eq!(runs_without_params(doc), bare, "{doc}"); } + // Panel menu commands whose params doc gives each optional field a type are listed too. + let none = Overrides::new(); + for id in [ + "type.fractionalWidths", + "type.systemLayout", + "object.setPixelAligned", + "links.embed", + "links.update", + "object.slice.delete", + "artboard.switchOrientation", + "appearance.targetContents", + "paint.invert", + "artboard.distributeSpacing", + ] { + assert!(entry(id).is_some_and(|e| listed(&none, &e)), "{id}"); + } let mut ov = Overrides::new(); assert!(assign(&mut ov, "symbol.breakLink", Some("Cmd+Alt+Shift+F9"), true).is_ok()); assert_eq!(effective_in(&ov, "symbol.breakLink").as_deref(), Some("Cmd+Alt+Shift+F9")); diff --git a/crates/ui-egui/src/shortcuts/typing.rs b/crates/ui-egui/src/shortcuts/typing.rs index cee164c7f8..2befac02c3 100644 --- a/crates/ui-egui/src/shortcuts/typing.rs +++ b/crates/ui-egui/src/shortcuts/typing.rs @@ -42,6 +42,8 @@ enum Seq { Text(String), Preedit(String, Option>), Commit(String), + /// A space the IME commits during a Zoom tool drag on the canvas, where Space moves the marquee. + MarqueeSpace, Erase(ToolKey, Mods), /// An input method replaces text around the composition (upstream's character counts or /// the fork's exact UTF-8 byte counts), then commits. @@ -82,6 +84,11 @@ pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { // Set once the IME commits text in this frame. let mut committed = false; for e in seq { + // With text marked, the IME's space commits that text as usual. + let e = match e { + Seq::MarqueeSpace if app.session.tool_composing() || app.ime_marked.is_some() => Seq::Commit(" ".into()), + e => e, + }; // Before the UI, a changed composition or guard is settled `pending`: a token bound here // could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to // the stock descriptor. The post-UI finalizer binds the confirmed replacement. @@ -94,10 +101,16 @@ pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { match &e { // Accepted old payloads precede the fence even when their terminal arrives. // None of these events proves the local reset has reached its boundary. - Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue, + Seq::Commit(_) | Seq::MarqueeSpace | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue, _ => {} } } + // Otherwise the IME's input ends there, and the space is not typed. + if matches!(e, Seq::MarqueeSpace) { + committed = true; + app.ime_compositions.remove(&viewport); + continue; + } // A pointer/navigation/command notice has not reached its real handler yet. Text and // erases after it belong to the old caret, not to the destination it will establish. if crate::ime::deferred(app, ctx) { @@ -135,10 +148,11 @@ pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { } /// Take this frame's typed text, IME events, editing keys, Enter and clipboard events, in the order -/// they arrived. +/// they arrived. A space typed during the canvas's Zoom tool drag is not typed. fn type_sequence(ctx: &egui::Context) -> Vec { let mut seq = vec![]; let mut edits = vec![]; + let zooming = crate::canvas::dragging_zoom(ctx); ctx.input_mut(|i| { // Move the events out instead of cloning their strings: a keyboard hook that reinjects a // batch keeps the batch in `seq` only, not in both the input list and a second copy. @@ -155,10 +169,13 @@ fn type_sequence(ctx: &egui::Context) -> Vec { .unwrap_or_else(|| i.pointer.primary_down()); for e in std::mem::take(&mut i.events) { match e { + // The zoom drag lasts until the primary button goes up. + egui::Event::Text(t) if zooming && primary_down && t == " " => {} egui::Event::Text(t) => seq.push(Seq::Text(t)), egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { seq.push(Seq::Preedit(text, active_range_chars)); } + egui::Event::Ime(egui::ImeEvent::Commit(t)) if zooming && primary_down && t == " " => seq.push(Seq::MarqueeSpace), egui::Event::Ime(egui::ImeEvent::Commit(t)) => seq.push(Seq::Commit(t)), egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { seq.push(Seq::DeleteSurrounding(before_chars, after_chars, SurroundingUnit::Chars)); @@ -254,7 +271,7 @@ fn apply_type_event( } Ok(vec![]) } - Seq::ResetCompleted(_) => Ok(vec![]), + Seq::ResetCompleted(_) | Seq::MarqueeSpace => Ok(vec![]), // Keys the IME passed through come as text only when nothing is marked. This is an // application edit (also a hook IME's reinjected characters): `Other` dominates. Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d06c0dbcb2..80a87b4563 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -424,10 +424,11 @@ pub struct UiState { /// is on screen. Not saved. #[serde(skip)] pub learn_locate: Option, - /// The tutorials whose practice samples were opened and the tutorials finished, kept while the - /// app runs. + /// The tutorials whose practice samples were opened, kept while the app runs. #[serde(skip)] pub learn_practice: crate::panels::learn::Practice, + /// The Learn panel's tutorials finished and steps marked done, saved with the preferences. + pub learn_progress: crate::panels::learn::progress::Progress, pub control_bar: bool, /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). @@ -1121,6 +1122,7 @@ impl Default for UiState { learn: Default::default(), learn_locate: None, learn_practice: Default::default(), + learn_progress: Default::default(), control_bar: false, control_bar_bottom: false, control_bar_floating: false, diff --git a/docs/development.md b/docs/development.md index 5dfd67a7c1..846d2c02d8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -125,7 +125,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: -- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol (`wl_data_device`) on the window's own connection, and only while the window has focus. One Copy offers the PNG as `image/png`, the SVG as `image/svg+xml` (with Include SVG Code on) and the text as `text/plain;charset=utf-8`, `UTF8_STRING` and `text/plain`; a format with no data is not offered. Paste reads `image/svg+xml`, then text, then `image/png`, and blank text or text that only names a picture gives way to the PNG; the Paste menu item is enabled by the types offered, without reading any data. Whether the clipboard still holds VectorCraft's Copy is told by its data source, which the compositor cancels when another app copies. A read takes 64 MiB at most and fails beyond that. Reads run on a worker thread: text waits 2 s at most on the UI thread, SVG and PNG 10 s on the paste's background thread. An owner that never sends its data costs one read its wait, and later reads return at once until it answers. When a text read gets no answer in its 2 s, the paste waits on its background thread for that read's answer, up to 10 s after the read was sent, then reads the clipboard again unless it holds VectorCraft's Copy. Another app's paste of a Copy is sent on a thread of its own, 8 at once at most; a paste beyond them gets nothing. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. +- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol (`wl_data_device`) on the window's own connection, and only while the window has focus. One Copy offers the PNG as `image/png`, the SVG as `image/svg+xml` (with Include SVG Code on) and the text as `text/plain;charset=utf-8`, `UTF8_STRING` and `text/plain`; a format with no data is not offered. Paste reads the files a file manager copied (`text/uri-list`, else `x-special/gnome-copied-files`; local `file:` URIs only, 1,024 at most), then `image/svg+xml`, then text, then `image/png`, and blank text or text that only names a picture gives way to the PNG; the Paste menu item is enabled by the types offered, without reading any data. Whether the clipboard still holds VectorCraft's Copy is told by its data source, which the compositor cancels when another app copies. A read takes 64 MiB at most and fails beyond that. Reads run on a worker thread: text waits 2 s at most on the UI thread, file lists, SVG and PNG 10 s on the paste's background thread. An owner that never sends its data costs one read its wait, and later reads return at once until it answers. When a text read gets no answer in its 2 s, the paste waits on its background thread for that read's answer, up to 10 s after the read was sent, then reads the clipboard again unless it holds VectorCraft's Copy. Another app's paste of a Copy is sent on a thread of its own, 8 at once at most; a paste beyond them gets nothing. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. - **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager when one runs. That includes a read of the Paste menu check's handle: quitting waits up to 27 s for the background thread to drop it before the app drops its own, and quits after that wait even when the handle has not been dropped. The app takes its windows off screen before these waits (`apps/vectorcraft/src/quit_windows.rs`). An X11 main window, an XWayland one included, is hidden. winit cannot hide a native Wayland window, so on Linux the app attaches no buffer to the main window's surface, which unmaps it while the window and its connection stay open for the Wayland clipboard. On Linux the X11 windows of floating panels and documents are unmapped through a second X11 connection. On native Wayland, floating panels and documents are normally drawn inside the main window; on the BSDs a native Wayland main window stays on screen until the waits end. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own. @@ -297,7 +297,7 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE placeholders are translated in turn (the reason after `: {e}` is often a message too). A literal brace in a row is written `{{` or `}}`, in the English and in the translation: a message made with `format!` keeps the doubled braces of its format string, and a plain string literal has each of its braces doubled, so the row for - `"expected {\"objects\": [...]}"` is `expected {{"objects": [...]}}`. Czech, German, Greek, Spanish, French, Georgian, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish, Turkish, Catalan and Dutch + `"expected {\"objects\": [...]}"` is `expected {{"objects": [...]}}`. Czech, German, Greek, Spanish, French, Georgian, Hindi, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish, Turkish, Catalan and Dutch translate status and error messages (`COMPLETE_MESSAGES` in `i18n::tests`); the untranslated report lists the message literals the scan finds, and the phrases a parameter schema's error builds at run time (`SCHEMA_PHRASES`), that one of them has no row for. The scan covers the crates in `MESSAGE_CRATES`, the diff --git a/docs/dimensions.md b/docs/dimensions.md index 9cba8a66dc..377ab9d698 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -66,7 +66,8 @@ Measurements follow the art they measure through moves, scaling and anchor and h undo and redo included. When the art is reflected, the line of a linked linear dimension goes to the reflected side, as far past the outermost point as before. This does not happen for art whose anchors and handles all lie on one line, such as a straight line, which a half turn puts in the -same place as a reflection. Deleting the art, changing its number of anchors or whether it is +same place as a reflection. An unlinked aligned dimension that is reflected, or scaled by a +negative factor, has its line on the reflected side of its points. Deleting the art, changing its number of anchors or whether it is closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular leaves its dimensions where they were, no longer linked, in the same undo step, rather than attaching them to a different edge. Undo links them again. A linked dimension moved on its own diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a211c0ca53..a5ff38be13 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -285,7 +285,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | DCS 1.0 / 2.0 | place linked | place linked only (one image of the plates; spot plates as spot swatches; the composite's clipping path; each plate on its own ink in Separations Preview and print separations) | no | `crates/eps/src/dcs/tests.rs`, `crates/engine/src/tests/dcs.rs` | -| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) | +| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); a masked group, or a knockout group's masked group, whose content has a shadow, glow, blur or feather starts with a hidden, unpainted rectangle over the mask's region, so readers that draw a masked group only inside the box of its shapes draw those effects too (`crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open | | HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index f1c0b7d3dd..52ac7a7848 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -109,6 +109,24 @@ The trace then uses only the named colors, and white while white is a color of i color that was not named is traced too: each of its pixels goes to the named color, white or transparent that has the largest share in the blend of them that comes closest to the pixel. +**Flat Logo with a color between two others.** When a traced color lies between two others, as a gray +lies between black and white, an anti-aliased edge pixel between the outer two is also a blend of the +middle color and one of them. Flat Logo then reads the pixel as the blend of the colors that lie nearest +it in the flat areas within 6 pixels, when the pixel has a neighbor darker and a neighbor lighter than the +middle color, along the line between the other two. A black disc beside a gray square on white traces +with one gray shape, the square, and no gray slivers around the disc, when its edges are sharp or blurred +by up to about half a pixel. An edge blurred by a pixel or more can still leave gray slivers along the +disc, and so can an image with noise of a level or more in its pixels. A gray stroke two or three pixels +wide beside black has no darker pixel next to it and stays gray. A soft-edged gray stroke about two +pixels wide that touches black traces as black and white, and one about three pixels wide that touches +black can lose most or all of its gray when it is short (12 pixels long or less). A line of gray one +pixel wide between black and white stays gray when at least 13 of its pixels touch: such a line and a +straight black-to-white edge whose pixels are as light as the gray give the same pixels, and both +trace as gray. A one-pixel gray line between black and white that is shorter than 13 pixels, falls +between pixels or is blurred traces as black and white. A flat area whose color lies between two named colors, as when the colors are named +some levels off, counts as holding the named colors it is a blend of, and so do the pixels of exactly +its color that touch it. + **Large images.** An image of more than 1500 pixels on a side is traced as any other, only more slowly, and the trace says so: `imageTrace.make` returns the note in `warnings` and the status bar shows it (the [results optimization](https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html) @@ -118,8 +136,9 @@ most 1024 pixels on a side: a larger decoded raster is averaged down in whole-nu image is traced at 513, and a 16,000 × 12,000 image, decoded at 9,459 × 7,094, is traced at 946 × 710), and the trace names both sizes in `warnings`. Flat Logo gives that note in place of the slower-trace note for an image over 1500 pixels and in place of the 64-megapixel note. An image at most 1024 pixels on a side is traced at its own size, also when the named `logoColors` differ slightly from the -image's colors. Only an image with more than 150,000 different pixel values that are not exactly one traced -color is averaged down further: the factor doubles until at most 150,000 remain or the working copy has at +image's colors. Only an image with more than 150,000 different pixel values to unmix (values that are +not exactly one traced color, and values that are, at pixels outside a flat area) is averaged down +further: the factor doubles until at most 150,000 remain or the working copy has at most 16,384 pixels (at most six times). A trace that would make more than 8,000,000 anchor points (2,000,000 on the web) is refused to protect memory, with advice: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset. A trace keeps diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d62b475105..3ba87b2d94 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -15,7 +15,7 @@ translation never fails a test or a build: the interface shows the English text `cargo xtask ci` prints the untranslated report (`cargo xtask i18n-report` alone), which lists per language the strings missing from each catalog with the `complete_menus` flag and the messages missing from each language in the `COMPLETE_MESSAGES` list. Every non-English language marked "full" has the flag, and all of them except -Bulgarian, Danish and Indonesian are in the list. No language has help content: there is no translated (or +Bosnian, Bulgarian, Danish and Indonesian are in the list. No language has help content: there is no translated (or English) in-app help, and the docs are English. **Native review:** every catalog was written clean-room from the English meaning, most of it by agents @@ -35,7 +35,7 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a | Gujarati | `gu` | 4,673 rows (2026-11-14): original 4,286 unchanged; all 387 additions contributor-reviewed and approved | menus and dialogs; status/error messages partial | installed Gujarati fonts on desktop; optional `Gujr` craft-fonts fallback; no Gujarati face in the pinned CI/release fonts or web startup list | original catalog reviewed by a native speaker; all 387 later additions approved by the contributor, with 11 wording corrections | contributor-reviewed catalog | remaining messages and a bundled Gujarati web font | | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 9,166 rows, 3,436 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 178 missing messages for it | all | Latin | no | full | — (review) | -| Portuguese | `pt-br` | 9,260 rows, 3,445 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 39 missing messages for it; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | +| Portuguese | `pt-br` | 9,378 rows, 3,563 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 55 missing messages for it, which open #7116, #7211, #7330, #7378 and #7405 translate; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | | Indonesian | `id` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one plural form | dialogs and tooltips all; 750 message rows (French has 796) | Latin | no | full | — (review) | | Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | | German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (3,563 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 52 missing messages for it, which open #7116, #7211 and #7378 translate) | Latin | contributor-written, not reviewed | full | — (review) | @@ -61,9 +61,9 @@ plus review. Native-speaker review is human work and isn't in the hours. | Hungarian | `hu` | 5,253 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | full | | Polish | `pl` | 9,044 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 46 missing messages for it, and 47 menu strings, 26 `tl!` literals, 11 undo labels and 45 swatch names; one/few/many plurals | full; native review pending | | Finnish | `fi` | 5,318 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals, 797 `@msg` rows | full; AI-assisted, native proofreading pending | -| Czech | `cs` | 9,155 rows, 3,445 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it | catalog complete; render-site gaps reduced; native review pending | +| Czech | `cs` | 9,273 rows, 3,560 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 2 missing Czech messages, and 54 messages that no language has a row for | catalog complete; render-site gaps reduced; native review pending | | Bosnian | `bs` | 5,841 rows (counted 2026-10-30): menus, `tl!` literals and messages complete when counted; one/few/other plurals | full; AI-assisted, native review pending | -| Swedish | `sv` | 5,251 rows, 795 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals | full; native review welcome | +| Swedish | `sv` | 8,991 rows, 3,106 of them `@msg` and 48 `@msgplural` (counted 2026-11-14): the untranslated report lists 518 missing messages for it, and 12 menu strings, 1 `tl!` literal and 3 undo labels; one/other plurals | full; native review welcome | | Bulgarian | `bg` | 5,245 rows, 763 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial | full; AI-drafted, native review pending | | Greek | `el` | 5,251 rows, 795 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | full; native review welcome | | Georgian | `ka` | 5,200 rows (counted 2026-10-21): menus, panels, dialogs, plurals (one count-neutral form) and messages, tested complete; reviewed by a native speaker | full | diff --git a/docs/mcp.md b/docs/mcp.md index e727c8ccc3..6e62fcb009 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -289,7 +289,13 @@ the suite) keep the agent to the folders you give it: - The preferences that move folders the app reads or writes on its own (`fontsFolder`, `pluginsFolder`, `recoveryFolder`, `templatesFolder`) can only be set inside the roots. The app's own folders (preferences, Data Recovery, the User Defined library folders, VectorCraft's Fonts folder) are not - confined: no agent names a path there. + confined. These commands that act on a file there are confined: `stroke.arrowheads.edit` and + `stroke.arrowheads.reload` refuse an arrowheads file outside the read roots, and `stroke.arrowheads.list` gives its + `file` as `null` there (see [Arrowheads](#arrowheads)); `file.newProfiles.edit` needs read and write access to the + New Document profile's file, `file.newProfiles.reset` needs write access, and `file.new` starts with the defaults + when the read roots leave out the profiles folder (see [New Document profiles](#new-document-profiles)); + `script.listInstalled` lists the Scripts folder and `ui.runInstalledScript` runs a script only inside a read root + (see [Script engines](#script-engines)). - The roots must be existing folders; a missing one stops the server with an error. Each flag takes one folder per use (`--flag <dir>` or `--flag=<dir>`) and may be used again for more. @@ -678,7 +684,8 @@ CJK composition. (the selection, or `ids`) and reply `{vertical, ids}`. The writing direction is a setting of the whole thread: the commands set every frame of a selected frame's thread, and `ids` lists them all. Threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and a file whose frames of one thread differ opens -with the first frame's direction in all of them. +with the first frame's direction in all of them. When every frame already has the direction, the command records no +undo step. For SVG/SVGZ, `export`'s top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options: {"advanced": {"outlineText": true}}`; its default is false (live text). @@ -894,13 +901,15 @@ Errors (unknown tool, bad arguments, a disabled or failing command) come back as `isError: true` and a message. The model can read the message and retry. Swatches go through `run_command`: `swatch.list` lists every swatch with its colour group, kind and colour; -`swatch.edit {name, newName?, color?, mode?, global?, spot?}` edits one (fills, strokes and text linked to a +`swatch.edit {name, newName?, color?, mode?, global?, spot?, paint?}` edits one (fills, strokes and text linked to a global or spot swatch follow its colour and name, as one undo step, in the art, opacity masks, symbols, graph -designs, pattern tiles, graphic styles and the art of brushes); `swatch.delete {names, unlink?}` deletes +designs, pattern tiles, graphic styles and the art of brushes). A replacement colour, gradient or pattern given as +`paint` (`{color}`, `{gradient}`, `{swatch}` or `{pattern: name}`) is stored as `swatch.new` stores it; +`swatch.delete {names, unlink?}` deletes swatches and colour groups in one undo step (art using a deleted global swatch keeps its colour, unlinked). `swatch.new {color?, mode?, spot?, global?, group?}` saves a colour (into a colour group with `group`), gradient -or pattern (a gradient swatch holds no link to the swatch its gradient came from, and a pattern swatch holds the -pattern untransformed); `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected +or pattern (a gradient swatch keeps the gradient's aspect, with or without `start` and `end`, and holds no link to +the swatch its gradient came from, and a pattern swatch holds the pattern untransformed); `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected art's colours (by default as global swatches the art links to). `swatch.move {names, to?, group?}` reorders swatches or moves them into or out of a colour group (as dragging them in the Swatches panel does); give colour group names instead to reorder the groups. Dropping a swatch on art @@ -2909,14 +2918,18 @@ own layout rules. Window › Learn and the Explore tutorials button beside the search icon at the right end of the app bar show and hide the Learn panel. Its For you tab holds a Get started section with a Quick skill guide card; choosing the card opens -the Skills tab. The Skills tab lists VectorCraft's own skills, each with its number of tutorials and an empty progress -ring. Choosing a skill lists its tutorials, choosing a tutorial shows its numbered steps, and Back goes up one level. +the Skills tab. The Skills tab lists VectorCraft's own skills, each with a progress ring and the count of its tutorials +finished of all its tutorials. Choosing a skill lists its tutorials, choosing a tutorial shows its numbered steps, and +Back goes up one level. The tutorial text is English. `window.learn {show?}` shows the panel (`show: true`), hides it (`show: false`) or toggles it (`show` omitted or null), and answers whether it shows; Window › Learn and Explore tutorials run it. The panel counts as hidden while the dock is hidden or a full screen mode hides the panels. Showing it shows a hidden dock; in a full screen mode it makes Learn the front panel, which shows when the mode ends, and answers false. Any -other value of `show` is an error and changes nothing. `learn.list {}` → `{skills: [{id, title, tutorials: [{id, title, steps}]}]}`, in the order the Skills tab -lists them, where `steps` is the number of steps. `learn.skills {}` shows the panel on its Skills tab with the list of +other value of `show` is an error and changes nothing. `learn.list {}` → +`{skills: [{id, title, done, total, tutorials: [{id, title, steps, finished, completedSteps}]}]}`, in the order the +Skills tab lists them, where `done` is the number of the skill's tutorials finished, `total` the number of its +tutorials, `steps` the number of a tutorial's steps, `finished` whether it is finished, and `completedSteps` the +zero-based indexes of its steps marked done (every step of a finished tutorial). `learn.skills {}` shows the panel on its Skills tab with the list of skills, with no skill and no tutorial open, as the Quick skill guide card and Back to skills do, and answers what `learn.list` answers. `learn.show {tutorial}` shows the panel on its Skills tab with that tutorial's steps → `{skill, tutorial, title, steps: [text]}`. A missing, non-string or unknown `tutorial` is an error @@ -2926,18 +2939,33 @@ Under a tutorial's steps, View more opens the tutorial's practice sample, a smal art its steps work on, as a new untitled document that becomes the active one; Save asks where to save it. Once the sample is open, Finish tutorial marks the tutorial finished. A finished tutorial shows Next Up with the title of the next tutorial in the same skill, which Next Up shows without opening its sample. A skill's last tutorial, finished, -says so and shows Back to skills instead; Next Up never moves to another skill. Which tutorials are finished is kept -while the app runs, and the skill rings and counts do not show it yet. These are VectorCraft's own rules; the help -page also plays a short video, which VectorCraft does not have. `learn.openSample {tutorial}` runs View more → +says so and shows Back to skills instead; Next Up never moves to another skill. These are VectorCraft's own rules; the +help page also plays a short video, which VectorCraft does not have. `learn.openSample {tutorial}` runs View more → `{tutorial, index, title}` of the new document, and moves the Learn view to that tutorial; each call opens another copy and leaves the open documents as they are. `learn.finish {tutorial}` runs Finish tutorial → `{tutorial, finished: true, next}`, where `next` is the next tutorial's id in the skill, or null after the skill's -last one; the tutorial's sample must have been opened, and finishing it again keeps one entry. `learn.next {tutorial}` +last one; the tutorial's sample must have been opened, and finishing it again keeps one entry, also after a restart +without opening the sample again. `learn.next {tutorial}` runs Next Up on a finished tutorial and shows the panel on the next one → `{skill, tutorial, title}`; on a tutorial not finished, or a skill's last tutorial, it is an error. For all three, a missing, non-string or unknown `tutorial` is an error, and an error changes nothing. Behavior reference: [Quick skill guide](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html). +Each step has a check box before its number that shows whether it is done, and a skill's ring fills as its tutorials +are finished, as the help page describes; a finished tutorial has a check mark in its skill's list. A step is marked +done by its check box, which can be changed once the tutorial's sample is open and until the tutorial is finished; +marking every step does not finish the tutorial. Finish tutorial marks all its steps done, and a finished tutorial's +steps stay marked. Reset progress under the list of skills, shown while there is progress, clears every tutorial +finished and every step marked. The desktop app keeps the progress in its preferences, so it lasts to the next start; +when two windows change it, the one that saves last keeps its progress. The web build keeps it while the page is open. +Marking steps by hand, keeping the progress and Reset progress are VectorCraft's own rules. +`learn.markStep {tutorial, step, complete?}` runs a step's check box → `{tutorial, step, complete}`: `step` is the +zero-based step number, and `complete` marks the step done (`true`, omitted or null) or not done (`false`). A missing +`step` or one the tutorial does not have, a `complete` that is not true or false, a tutorial whose sample was not +opened, and a finished tutorial are errors. `learn.resetProgress {}` runs Reset progress and answers what `learn.list` +answers; open documents, opened samples and the Learn view stay as they are. For both, a missing, non-string or +unknown `tutorial` is an error, and an error changes nothing. + The Home screen has a Learn section between Recent Files and the new-file presets, as [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/homescreen-overview.html) lists Learn, with in-app tutorials, among the Home screen's options. The section holds the Quick skill guide card, @@ -2949,9 +2977,11 @@ rules. ```json {"name":"invoke_menu","arguments":{"command":"learn.show","params":{"tutorial":"pen-paths"}}} {"name":"invoke_menu","arguments":{"command":"learn.openSample","params":{"tutorial":"pen-paths"}}} +{"name":"invoke_menu","arguments":{"command":"learn.markStep","params":{"tutorial":"pen-paths","step":0}}} {"name":"invoke_menu","arguments":{"command":"learn.finish","params":{"tutorial":"pen-paths"}}} {"name":"invoke_menu","arguments":{"command":"learn.next","params":{"tutorial":"pen-paths"}}} {"name":"invoke_menu","arguments":{"command":"learn.skills","params":{}}} +{"name":"invoke_menu","arguments":{"command":"learn.resetProgress","params":{}}} ``` A step that names a tool shows the tool's name as a blue link. A click on it, or Enter or Space while it has the @@ -3151,7 +3181,12 @@ already walks Image Trace. Short agent entry points: is unused, and offers Remove Strokes or Cancel when one of them is in use. - Brushes: `brush.list`, `brush.get {name}`, `brush.apply {name, ids?}`, `brush.new {type, name?, params?, ids?}`, `brush.setCurrent {name}`, `brush.options {name, params?, newName?}`, `brush.delete {name} | {names}`, - `brush.duplicate {name, newName?} | {names}`, `brush.freehand`, `object.expandBrush {ids?}`. A document or brush + `brush.duplicate {name, newName?} | {names}`, `brush.freehand {…path.freehand params, brush?}`, + `object.expandBrush {ids?}`. `brush.freehand` draws the plain path when `brush` is left out or null. It refuses a + `brush` that is not a string with "`brush` must be a string", a name that no brush in the document has (an empty + name included) with "no brush named `<name>`", and a `style` other than "pencil" or "brush"; the document does not + change and no undo step is recorded. The Paintbrush skips a tool brush or current brush that has been deleted and + paints with the other, or draws the plain path when neither is in the document. A document or brush library file whose brushes repeat a name is read with the first brush of that name as stored and each later one numbered with the first "`<name>` 2", "`<name>` 3"… the file does not have; strokes of the name keep the first brush. `brush.new`, @@ -4370,7 +4405,11 @@ with); without `path` it returns `{data}`, and `user: true` saves into the user (category `user`, User Defined). `graphicStyle.loadLibrary {path? | data? | dataBase64?, name?}` loads a `.vcstyles` file, or another document's graphic styles, as a library to add from (a `.ai` or EPS whose editing data holds graphic styles and no layers gives the styles of its style list, in that order); a file in the user library folder, or a file -with the same extension and bytes as one there, loads as that User Defined library. +with the same extension and bytes as one there, loads as that User Defined library. A `.ai` or EPS style that paints a +fill or stroke with one of the file's gradients by name loads with that gradient at the style's angle. The style stores +only the gradient's name and angle, so the gradient is fitted to the bounds of each object the style is applied to; this +placement is VectorCraft's own rule. A style that names a gradient the file doesn't define, or paints with a pattern, is +left out. ## Symbol libraries @@ -5462,7 +5501,9 @@ too and printer's marks are not. The destination is any profile `edit.colorSetti for its colour mode). `profiles` (`all`, `destination`, or `taggedSource` for a document assigned profiles with `edit.assignProfile`) writes colours in ICC-based spaces with their profiles embedded: CMYK with the destination's or the document's CMYK profile, RGB as sRGB, grey with the sRGB tone curve (PDF/A, PDF/X-3 and PDF/X-4 files are -always tagged, whatever `profiles` says). `outputIntent` embeds a profile as the file's +always tagged, whatever `profiles` says). A CMYK image that keeps its numbers carries the CMYK profile it embeds +with `all` and `taggedSource`, and in PDF/A, PDF/X-3 and PDF/X-4 unless `profiles` is `destination`; PDF/X-1a +images are always untagged CMYK. `outputIntent` embeds a profile as the file's `/GTS_PDFX` output intent with `outputCondition`, `outputConditionId` and `registry`, and `trapped` sets `/Trapped` (PDF/A files keep their own output intent). Type is written as selectable, searchable text in embedded subset fonts with a ToUnicode map (fonts whose licence forbids embedding stay outlines, with a warning); `advanced: {outlineText: @@ -5723,6 +5764,15 @@ tool's active artboard in place, and the Distribute buttons then send the Distri can then be negative. Align to Selection sends `to: "selection"`. The Distribute buttons need two or more selected artboards with Align to Artboard, and three or more with Align to Selection. +Edit › Keyboard Shortcuts and `shortcuts.list` list an undoable command that is not an alias and has no menu path or +default shortcut, such as a panel menu's Break Link to Symbol (`symbol.breakLink`), when its params doc names no +required field and it is not one of the commands left out below. A field is optional when each of its names ends with +`?`, whatever type follows it (`on?: bool`, `ids?: [id…]`), when its name is followed directly by an `=` default +(`rows=10`), or when it starts with `…`. Such a command is listed in the group named by the first part of its id. +Commands listed this way include `type.fractionalWidths`, `type.systemLayout`, `object.setPixelAligned`, +`links.embed`, `links.update`, `object.slice.delete`, `artboard.switchOrientation`, `artboard.distributeSpacing`, +`appearance.targetContents` and `paint.invert`. + Edit › Keyboard Shortcuts and `shortcuts.list` leave out a command with no default shortcut when a key press cannot run it: no item of the menu bar runs it, it opens no dialog when run without params, and it is one of these: @@ -5732,7 +5782,22 @@ run it: no item of the menu bar runs it, it opens no dialog when run without par - a command that sets nothing or fails without a param: `transparency.set`, `transparency.setOpacityMask`, `stroke.set`, `layer.setProps`, `object.align`, `object.distribute`, `swatch.delete`, `swatch.move`, `swatch.spotOptions`, `edit.colors.adjustBalance`, `swatch.library.load`, `graphicStyle.loadLibrary`, - `symbol.loadLibrary`, `brush.loadLibrary`, `dataset.import` and `variable.loadLibrary`. + `symbol.loadLibrary`, `brush.loadLibrary`, `dataset.import`, `variable.loadLibrary`, `charStyle.load`, + `paraStyle.load`, `textStyle.loadAll` and `svgFilter.import`; +- a command without a menu path that sets nothing, fails or only gives data back without a param: + `addon.pathtools.setAnchor`, `addon.shapes.configure`, `appearance.addEffect`, `artboard.align`, + `artboard.distribute`, `assets.export`, `assets.export3D`, `assets.settings.set`, + `attributes.set`, `brush.move`, `css.export`, `dimension.create`, `document.export`, `document.exportDxf`, + `document.exportEps`, `document.exportForWeb`, `document.exportPdf`, `document.save`, `document.setup`, + `edit.undoGroup.begin`, `edit.undoGroup.end`, `eyedropper.setOptions`, `file.package`, `library.createGroup`, + `links.placementOptions`, `links.unembed`, `livePaint.gapOptions`, `object.blend.createSpine`, + `object.blend.setEasing`, `object.intertwine.arrange`, `object.objectsOnPath.options`, `object.puppetWarp.pins`, + `object.setBounds`, `object.setLiveShape`, `object.setOverprint`, `object.setProps`, `object.slice.select`, + `paint.freeform.setPoint`, `paint.setFill`, `paint.setGradientGeom`, `paint.setStroke`, `perspective.grid.define`, + `perspective.grid.set`, `perspective.plane.move`, `print.setup`, `select.color`, `select.globalEditOptions`, + `stroke.setAdvanced`, `symbol.options`, `text.setFormat`, `text.setRangeStyle`, `text.setStyle` and `type.ruby`; +- `symbol.new`, which the Symbols panel's New Symbol button and its New Symbol… menu item run through the Symbol + Options dialog. They list such a command while the user has changed its shortcut (`overridden: true` in `shortcuts.list`), and `shortcuts.set` can change or clear it. When every menu bar item that runs a command gives it the same params, a @@ -5849,7 +5914,12 @@ files or data sets, 8 folder levels, 256 steps. `action.batch` refuses `includeS `` `stopOnError` must be true or false ``, and `source`, `folder`, `destination`, `destinationFolder`, `datasetFileNaming` or `label` given as anything but a string with `` `label` must be a string ``, naming the key. It checks them, and that `source` is `folder` or `datasets`, before it makes the destination folder or opens a file. -A null is read as a key left out. An action recorded in the Actions panel leaves the artboard out, +A null is read as a key left out. When a step fails on a file or data set, that row's `error` names the step by its +place in the `commands` sent, from 1, counting the Open steps the batch skips: with `commands` of an Open, an Unlock +All and an Align, a failed Align gives `` action step 3 (`object.align`) failed: `` and the reason. The batch's own +opening of a source file, choosing of a data set and destination save, export or close give their errors without a +step number. This numbering is VectorCraft's own rule. A `prefs.import` step is refused before any file is opened, +with `` commands[1]: `prefs.import` runs on its own, not as a batch step `` (counted from 0, as for an unknown command). An action recorded in the Actions panel leaves the artboard out, and its steps of these commands get the artboard that is active when the action plays. A press with the Selection, Direct Selection or Group Selection tool inside an artboard makes that artboard the active one; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. Choosing the Artboard @@ -7099,7 +7169,7 @@ comes back to the front, following the same preference. `links.check` reports ev `missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. `links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other stored object linked to the same file as -one of them, in the layers, opacity-mask art, symbols, graph designs and patterns; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in +one of them, in the layers, opacity-mask art, symbols, graph designs, patterns and the art of the stored brushes; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in `path`'s folder, and lists the names not there in `notFound` (with `copy`, only missing images are looked for); `each` takes `paths: [{ids, path}]` in place of `ids`, `path` and `folder` and relinks each link to its own file. An unknown scope, `paths` without `each`, `each` with `ids`, `path` or `folder`, an entry without `path` or `ids`, an id in two entries and `missingInFolder` with `folder` are @@ -7107,9 +7177,11 @@ errors, and nothing is relinked. Each call is one undo step. The Links panel's R (`ui.relinkDialog`) with a Replace menu of these four choices. A PNG, JPEG, another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. `links.update`, and `document.open` when it reads a modified file, also read the file again for the images and placed -documents inside symbols, graph designs, patterns and opacity masks that show the same content. Those objects have ids +documents inside symbols, graph designs, patterns, opacity masks and the art of the stored brushes (a scatter or art +brush's art, a pattern brush's tiles) that show the same content. Those objects have ids of their own: `links.list`, `links.check`, `links.findFiles`, the `missingLinks`, `modifiedLinks` and `updatedLinks` -rows and the link watch list them, and every `links.*` command takes their ids. Opening a document gives an object +rows and the link watch list them, and every `links.*` command takes their ids. A `links.*` command that changes a +brush's art writes it back into the brush library with those ids. Opening a document gives an object there whose id is 0 or already used by another object a new id, and the next save writes it. Saving a document also reads it again for the placed documents of it inside that art in the other open documents. A PNG, JPEG, GIF or WebP file whose pixels can't be read is not shown: `links.relink` fails with "the image's pixels can't be @@ -7351,10 +7423,12 @@ listed in `warnings`. ASCII and binary DXF files are read; DWG files answer an e `links.list {show?: all|missing|modified|embedded, sort?: name|kind|status}` lists every image in the layers, top first, as the Links panel does, then every image and placed document in opacity-mask art and in the art of symbols, -graph designs and patterns, one row per stored object (never one per symbol instance or pattern tile): `{id, name, -linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?, page?, preview?, note?, -owner?}` (`owner`, for an object outside the layer tree: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`, -`maskOf` naming the object whose opacity mask holds it; `note`: the image takes more than the decode allowance, 2 GiB +graph designs, patterns and stored brushes, one row per stored object (never one per symbol instance, pattern tile or +brushed stroke): `{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, +found?, page?, preview?, note?, owner?}` (`owner`, for an object outside the layer tree: +`{kind: layers|symbol|graphDesign|pattern|brush, name?, maskOf?, index?, tile?}`, `maskOf` naming the object whose +opacity mask holds it, `index` a brush's place in the library as `brush.list` lists it and `tile` the pattern brush tile +that holds it: `side`, `outerCorner`, `innerCorner`, `start` or `end`; `note`: the image takes more than the decode allowance, 2 GiB or 512 MiB in the web app, as RGBA or as decoded, and it is drawn at the largest size within the allowance ("shown at reduced size"; the document keeps the whole file) or, when it can't be read that way or has more pixels than the allowance has bytes, drawn as a crossed gray box on the canvas and left out of exports ("too large to draw"); `document.open` and `file.place` say the same in their `warnings`). `links.info {id?}` is the Link Info: `image.info`'s fields plus @@ -8933,6 +9007,7 @@ for the selected type objects (or `ids`); without `on` it sets the opposite of t on. Optical Margin Alignment is a setting of the whole thread: the command sets every frame of a selected frame's thread, and `ids` lists them all. Threading frames gives the thread its first frame's setting, frames taken out of a thread keep it, and a file whose frames of one thread differ opens with the first frame's setting in all of them. +When every frame already has the setting, the command records no undo step. In every paragraph of point and area type, these characters hang outside the margin when they start or end a line: quotation marks, hyphens (a hyphenated line's hyphen too), full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half their width; and A, V, W, Y, v, w @@ -9719,7 +9794,9 @@ and turn, shear and flip about it (`origin`). WebAssembly plug-ins ([plugins.md](plugins.md)) are reached through commands; the desktop app and an unconfined `vectorcraft-cli` keep the ones `plugin.install` installs in a `Plug-ins` folder next to the preferences and install -them again at startup (`plugin.remove` deletes them there): +them again at startup (`plugin.remove` deletes them there). A window or process locks `disabled.lock` in that folder +while it changes it, so installs, removals, grants and plug-in settings from two of them do not mix; when the file can't +be locked, `plugin.install` and `plugin.remove` fail and change nothing: `plugin.install {path | dataBase64}` installs one, `plugin.list` / `plugin.info {id}` describe them (parameters, schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs` @@ -10117,8 +10194,10 @@ Perspective grid presets: `perspective.presets.list` lists the built-in views (` `[1P-High View]`, `[2P-Normal View]`, `[2P-Low View]`, `[2P-High View]`, `[3P-Normal View]`, `[3P-Low View]`; `builtIn: true`, fitted to the first artboard, protected), then the saved ones, each with its Define Grid fields. `perspective.grid.preset {name}` (or `{kind}`, that type's normal view) resets the grid to one, fitted to the first -artboard. `perspective.presets.save {name?, newName?, preset?, …fields}` saves a preset: just `{name}` saves the -document's grid (View → Perspective Grid → Save Grid as Preset). `perspective.presets.delete {name}`, +artboard. It adds no undo step when the document already holds that grid. When only whether the grid shows or its +active plane differs, it sets them without an undo step, as `perspective.grid.show` and `perspective.plane.set` do. +`perspective.presets.save {name?, newName?, preset?, …fields}` saves a preset: just `{name}` saves the document's grid +(View → Perspective Grid → Save Grid as Preset). `perspective.presets.delete {name}`, `perspective.presets.export {names?, path?}` (a `.vcperspective` JSON file; without `path` it returns `data`) and `perspective.presets.import {path? | data? | dataBase64?, replace?}` manage them. Saved presets are kept with the preferences, and a grid whose fields are a preset's is named after it (`name`). @@ -10778,8 +10857,12 @@ topmost piece. ### Text tools The add-on's text tools act on the selected type (or `ids`), each as one undo step, and refuse with an error when there -is nothing for them to do. The pieces they make are point type that keeps its characters' styles, each placed so the -glyph of its first character lands where it was drawn. +is nothing for them to do. From the selection they take only type that can be edited; the type that `ids` names, or +that is inside a group or layer it names, is taken even when it, its group or its layer is locked or hidden (not type +on a locked artboard). The objects they make from locked or hidden type are not selected. Combine Point Text and Join +Point Text join a piece only with pieces that are as visible and as locked as it is, counting their groups and layers. +The pieces they make are point type that keeps its characters' styles, each placed so the glyph of its first character +lands where it was drawn. - `addon.cleanup.breakText {by?, ids?}` turns each horizontal point or area type object into point type objects, one per line (`by: "lines"`, the default), paragraph (`"paragraphs"`), word (`"words"`) or glyph (`"glyphs"`; a ligature @@ -10791,14 +10874,15 @@ glyph of its first character lands where it was drawn. staying where it was (within 0.01 pt). Pieces turned and sheared alike whose baselines lie within 0.4 em of each other join left to right (a baseline a little off becomes baseline shift); a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps its place. A gap wider than 10 em, an overlap of - more than 1 em, another look (paint, opacity, effects) or a piece that can't keep its place once joined (a ligature - forming across the join) starts another object. Each object made takes the place and paint of its leftmost piece. The - main use is type from a PDF that arrives one glyph per object. + more than 1 em, another look (paint, opacity, effects), another visibility or lock state, or a piece that can't keep + its place once joined (a ligature forming across the join) starts another object. Each object made takes the place + and paint of its leftmost piece. The main use is type from a PDF that arrives one glyph per object. - `addon.cleanup.joinText {ids?}` (Join Point Text) joins one-line horizontal point type into one point type object, - for text that arrives one line or fragment per object. Pieces turned and sheared like the top-left one are read as - lines (baselines within 0.4 em), top to bottom; a line's pieces join left to right with a space between them. Each - line becomes a left-aligned paragraph whose leading is the distance from the baseline above, so the lines stay where - they were. Character styles stay; the object takes the top-left piece's place and paint. Returns `{id, joined, lines}`. + for text that arrives one line or fragment per object. Pieces turned and sheared like the top-left one, and as visible + and as locked as it is, are read as lines (baselines within 0.4 em), top to bottom; a line's pieces join left to + right with a space between them. Each line becomes a left-aligned paragraph whose leading is the distance from the + baseline above, so the lines stay where they were. Character styles stay; the object takes the top-left piece's + place and paint. Returns `{id, joined, lines}`. - `addon.cleanup.removeBreaks {kind, ids?}` removes the forced line breaks (`kind: "line"`) or paragraph returns (`"paragraph"`) in the characters selected with a Type tool, else in all the text of the selected type. After Select All in a frame of threaded text, it removes them from the text of every frame of the story, as one undo step. @@ -11505,7 +11589,7 @@ as `object.setBounds` measures them (Use Preview Bounds), except Arrange in Grid | `addon.arrange.replace` | `fit`: `keep`, `bounds` (the replaced object's width and height), `proportional` (fits inside them) (`keep`); `scaleStrokes`; `keep` (false) | Puts a copy of the topmost selected object centred on each of the other selected objects, in that object's place in the stacking order; the replaced objects are deleted unless `keep` (the copy then goes right above each). Needs two objects or more; the copies are selected. → `{ids}` | | `addon.arrange.size` | `width`, `height` pt, 0.001…1000000 (at least one); `proportional` (false: a side not given stays; true: it follows the given one in proportion); `reference` 0…8 (4, the centre); `scaleStrokes` | Gives every selected object that width and height, keeping its reference point in place (the 9-point grid row by row from the top-left). | | `addon.arrange.groupOverlapping` | none | Groups the selected objects (at most 20000) wherever their visual bounds overlap or touch, chains included: when A overlaps B and B overlaps C, all three go in one group. Only objects in the same layer or group are grouped together; objects that overlap nothing stay as they are. Each new group goes where its frontmost member was in the stacking order, and the groups and the objects left alone stay selected; with nothing overlapping it makes no step. Needs two objects or more. → `{groups: [id…], grouped}` (how many objects went into groups) | -| `addon.arrange.randomizeOpacity` | `min`, `max` % 0…100 (30, 100; `min` not more than `max`); `seed` whole number (0); `ids` (the selection) | Gives each selected object its own opacity (the object opacity the Transparency panel sets), picked at random and evenly from `min` to `max`; the same seed, objects and range give the same opacities. Groups take one opacity of their own; the objects inside keep theirs. → `{count}` | +| `addon.arrange.randomizeOpacity` | `min`, `max` % 0…100 (30, 100; `min` not more than `max`); `seed` whole number (0); `ids` (the selection) | Gives each selected object its own opacity (the object opacity the Transparency panel sets), picked at random and evenly from `min` to `max`; the same seed, objects and range give the same opacities. Groups take one opacity of their own; the objects inside keep theirs. A run that changes no opacity, such as `min` and `max` both at the opacity every object has, records no undo step. → `{count}` | | `addon.arrange.sort` | `by`, `apply`: `width`, `height`, `area` (width × height), `x` (left edge), `y` (top edge), `opacity` (%), `stacking` (stacking order from the back) (`area`, `stacking`); `order`: `ascending` (small to large), `descending` (large to small), `random` (`ascending`); `distribute` (false); `seed` whole number (0, for `random`); `scaleStrokes`; `ids` (the selection) | Sorts the selected objects (at most 20000) by `by` and gives them the values of `apply` in that order: the first gets the smallest value. Without `distribute` the objects trade the values they have; with it the values step evenly from the smallest to the largest. A width, height or area changes about each object's centre (an area in proportion), a place moves the top-left corner. Stacking order always trades places, and only among objects in the same layer or group. Needs two objects or more. → `{count}` | Zoom to Selection is the UI command `addon.arrange.zoomSelection {}` (remote mode only): it zooms and centres the diff --git a/docs/plugins.md b/docs/plugins.md index fac6460249..dd3df7f7ac 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -269,7 +269,13 @@ capability model proposed for PhotoCraft plug-ins while `plugin.install` checks a module, the install is refused, before anything is written to the `Plug-ins` folder, if the other plug-in would need `replace: true` or the changed grant no longer approves the module. A `plugin.remove` in another session waits until the install has written the `Plug-ins` folder, and then removes the - module and its grant. Loading the `Plug-ins` folder or a plug-ins folder in another session waits for it too. When + module and its grant. Loading the `Plug-ins` folder or a plug-ins folder in another session waits for it too. + Another process using the same `Plug-ins` folder, for example `vectorcraft-cli` beside the app, waits as well: each + window or process locks `disabled.lock` in the folder while it reads and changes the stored modules, `grants.json`, + `disabled.json` and the plug-ins' settings and caches. An install therefore keeps a module with its own grant, and a + grant or setting written in one process does not undo one written in another. When `disabled.lock` can't be + locked, `plugin.install` and `plugin.remove` fail and change nothing, `plugin.setEnabled` keeps its change in the + process until a later change can be saved, and loading the folder writes nothing to it. When an install of a module that lists no permissions can't write the module to the `Plug-ins` folder, or can't remove the grant from `grants.json`, the folder keeps the module it held and `grants.json` keeps the grant. The new module runs until the session ends. When the grant can't be diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8c0669b592..8cf4af657a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -612,6 +612,7 @@ since 2026-10-01. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. - **PDF editable import:** Place from bytes or a path with `link: false`, and `clipboard.importPdf` followed by Paste, preserve live type on the selected page. Place still honors `textAs: "outlines"`. Synthetic two-page regressions: `crates/engine/src/cmd/fileio/tests_pdffidelity.rs`. No change to the PDF reader's existing fallback rules. - **Tab stops in `.ai`/EPS type and right-aligned lines with tabs (#6147):** type from the text document keeps its paragraphs' tab stops (key 30: positions and left, center, right and decimal alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. In right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none when the tab ends the line, with its hyphen when the line is hyphenated) against the right edge; centred and justified lines, point type and right-to-left paragraphs are aligned as a whole as before. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`. Scores unchanged. +- **`.ai`/EPS graphic styles that paint with a named gradient:** a graphic style library's style (or a file's listed style) whose fill, or the fill or stroke of a style of one paint, paints with one of the file's gradients by name loads with that gradient at the style's angle, fitted to the bounds of each object it is applied to (VectorCraft's own placement; the style stores only the gradient's name and angle). A style naming a gradient the file doesn't define, or painting with a pattern, is still left out. Evidence: `fileio::tests_aiimport::graphic_styles_in_a_ai_library_that_paint_with_a_named_gradient_load_with_it`, `style::tests::a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on`. Scores unchanged. - **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. @@ -736,7 +737,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup › Color:** a Color tab shows the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared: the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. OK runs `edit.assignProfile` and then `file.documentColorMode` with the dialog's other changes in one `command.batch`, and the document's changes are one undo step. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open. The tab is VectorCraft's own layout; File › Document Color Mode and Edit › Assign Profile… stay as they are. Tests in `dialogs/document_setup.rs`. - **Canvas through the display's profile on Windows and X11:** Preferences › Performance › Use Monitor Profile for Canvas (on by default) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof, as the help page describes the monitor's profile translating the document's colors for the monitor ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). Windows gives the display's default profile, and a display with Advanced Color on is left to Windows ([Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/wcs/advanced-color-icc-profiles)); X11 gives colord's profile for the output or the `_ICC_PROFILE` property. macOS converts the window itself, and a Wayland session stays sRGB. `ui.inspect` reports `displayProfile`. Tests in `color/src/cms/display.rs`, `ui-egui/src/display_profile.rs` and `canvas::tests::the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on`. - **Out-of-gamut warning after a profile change:** the out-of-gamut warning and its closest-color correction in the Color panel and Color Picker ([the help page](https://helpx.adobe.com/illustrator/using/adjusting-colors.html)) check the color against the document's assigned CMYK profile, else the working CMYK space, and update when Edit › Assign Profile… or Edit › Color Settings… changes that space or a loaded profile changes, without a change of color. Which CMYK space the warning uses is VectorCraft's own rule. Tests: `gamut_warning_follows_a_profile_change` and `gamut_warning_follows_a_profile_loaded_again` in `dialogs/color_picker/tests.rs`. -- **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. The profiles in use follow the document in front: a new, opened or reverted document, a switch to another tab, a close, Undo and Redo of Assign Profile, a failed batch, each step of a batch, Export All Open and the update of placed documents put that document's profiles in use, and the working spaces where it is assigned none or no document is open. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. +- **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. The profiles in use follow the document in front: a new, opened or reverted document, a switch to another tab, a close, Undo and Redo of Assign Profile, a failed batch, each step of a batch, Export All Open and the update of placed documents put that document's profiles in use, and the working spaces where it is assigned none or no document is open. With Window › Arrange › Tile or floating document windows, each document behind the front one draws its art, paper and transparency grid through its own assigned profiles, and loading or removing a profile redraws the documents assigned it; placed documents inside it still draw through the front document's profiles. How documents behind the front one draw is VectorCraft's own rule. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. - **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `profile_errors_name_the_file_by_its_name_outside_the_read_roots`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). @@ -771,6 +772,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). The folder and file names drop trailing periods and spaces (`a..svg` gives `a`). When two source files would get the same name that way (compared without case), or a later file's folder or file would take the name of an output an earlier file wrote, of a source file in the destination folder or, for data sets, of a file already there, Batch adds `-2`, `-3`… to the later one's name. + - Batch…: when a step fails on a file or data set, the error names the step by its place in the action's steps, from 1, counting the Open steps the batch skips (`action.batch`). This numbering is VectorCraft's own rule. Tests `cmd::fileio::action_batch::tests::{a_folder_batch_error_names_the_step_in_the_commands_sent, a_recorded_open_that_fails_is_the_first_step, a_data_set_batch_error_names_the_step_in_the_commands_sent, a_redirected_export_that_fails_keeps_its_place, a_source_that_does_not_open_names_no_step}`. - Actions panel menu: Save Actions… writes the current action set with all its actions to a file, and Load Actions… adds the set from such a file to the panel ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html); `ui.actionSet.save`, `ui.actionSet.load`). The file is VectorCraft's own action set file (`.vcactions`); a loaded set named like one in the panel gets the next free number. - Actions panel menu: Load Actions… keeps a step whose command this build or its installed plug-ins do not have, and reports each one when the set loads: the `ui.actionSet.load` answer lists the action, step and command id (`unknownCommands`, `unknownCount`), and the status bar names the first and the count. An action with such a step included does not play, and the error names the step and its id before any step runs; an excluded one does not stop it. These are VectorCraft's own rules. Tests `panels::actions::tests::{load_actions_reports_steps_with_unknown_commands, load_actions_bounds_what_it_reports_of_unknown_commands, an_action_with_an_unknown_command_does_not_play}`. - Actions panel menu: Save Actions… does not write a set with a step that sets a folder preference (the Fonts, Plug-ins, Templates and Recovery folders and the two scratch disks) to a folder, and names the action and the preference instead; an empty folder and the scratch disk choices Startup and None save. Export Preferences leaves out both scratch disks, and `ui.inspect` under automation read roots leaves such a folder outside them out of recorded steps. These are VectorCraft's own rules (`ui.actionSet.save`, `prefs.export`). @@ -883,7 +885,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent. - A layer whose Layer Options › Print is off shows its name italic in the Layers panel, and while its row is dragged, as the reference application shows non-printing layers (#5739); a printing layer stays upright and ticking Print again restores the upright name (`panels::layers` test `a_layer_that_does_not_print_has_its_name_italic`). - In the Layers panel only the primary mouse button drags. A drag with the right or middle button on a row, a guide row, the selection square, the eye or lock column or a target circle moves, copies, hides, locks and transfers nothing and records no undo step, and a right-click still opens the row's menu (`panels::layers` test `only_the_primary_button_drags_rows_guides_art_columns_and_appearances`). - - Rows dragged in the Layers panel and held within 28 points of the list's top or bottom edge scroll a long list that way, faster nearer the edge (60 points a second at the band's inner side, 340 at the edge, at every frame rate), and keep scrolling while the pointer stays still, so a row out of view can take the drop (a move, or a copy with Alt); the Libraries panel's list scrolls by the same rule. A pointer beside the list, a drag of the eye or lock column and a release stop the scrolling, and scrolling changes nothing in the document. The band and speeds are VectorCraft's own (`panels::layers` tests `rows_held_near_the_bottom_or_top_edge_scroll_the_list_to_a_row_out_of_view`, `a_held_row_scrolls_the_list_as_far_in_the_same_time_at_any_frame_rate` and `the_list_scrolls_only_for_rows_held_over_it`). + - Rows dragged in the Layers panel and held within 28 points of the list's top or bottom edge scroll a long list that way, faster nearer the edge (60 points a second at the band's inner side, 340 at the edge, at every frame rate), and keep scrolling while the pointer stays still, so a row out of view can take the drop (a move, or a copy with Alt); the Libraries panel's list scrolls by the same rule. In a list view shorter than 56 points each band is half the view, so a pointer in the top half scrolls up, one in the bottom half scrolls down, and a pointer just outside the view does not scroll it toward the far edge. A pointer beside the list, a drag of the eye or lock column and a release stop the scrolling, and scrolling changes nothing in the document. The band and speeds are VectorCraft's own (`panels::layers` tests `rows_held_near_the_bottom_or_top_edge_scroll_the_list_to_a_row_out_of_view`, `a_held_row_scrolls_the_list_as_far_in_the_same_time_at_any_frame_rate` and `the_list_scrolls_only_for_rows_held_over_it`; `panels` test `drag_scrolling_in_a_short_list_goes_toward_the_nearer_edge`). - The other panels' item drags start only with the primary mouse button too: swatches and color groups, brushes, symbols, graphic styles, library items and groups, character and paragraph styles, Appearance rows and its thumbnail, the Fill and Stroke squares, Recent Colors and the Gradient panel's thumbnail. A right or middle drag on one carries nothing, moves, copies, deletes and applies nothing and records no undo step; right-click menus and the Graphic Styles panel's held right-button preview stay as they were (`panels::swatches` test `only_a_primary_drag_moves_swatches_and_groups`, `panels::text_styles` test `only_a_primary_drag_carries_a_style_row`, `panels::tests_symbol_libraries` test `only_a_primary_drag_carries_a_library_symbol_out`, `panels::tests_effectedit` test `only_a_primary_drag_carries_the_thumbnail_or_a_row`). - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. - A floating panel's OS window hides while a tool group's flyout or the All Tools drawer is open in the main window, as it does under a modal dialog or a menu, and shows again when the flyout or the drawer closes (#5813; `a_tool_flyout_hides_the_palette_windows_until_it_closes`). @@ -904,6 +906,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Italian messages (2026-11-13): the Italian catalog (`it`) has 1,802 more status and error message rows, and the untranslated report's missing Italian messages go from 1,842 to 40; the translations are VectorCraft's own and await native review; scores and estimates unchanged. See [localization-parity.md](localization-parity.md). - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). + - The Czech catalog (`crates/ui-egui/src/i18n/cs.tsv`) translates 114 status and error messages that no catalog had a row for: the 3D and Revolve limits, image export and encoding errors, color profiles, color books and swatch exchange files, binary DXF and DWG import, path operations, swatch, graphic style and action set libraries, and zip, script and regular expression errors. The untranslated report (`untranslated_report`) still lists 54 messages for every language: the 53 composite font, SVG filter, read root and graph design errors that open #7211, #7378 and #7405 translate, and `` `index` must be a whole number ``. See [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - The Polish catalog (`crates/ui-egui/src/i18n/pl.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,439 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 46 Polish messages: 44 that open #7211, #7214, #7116, #7330 and #1931 translate, and 2 that no language has a row for. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). @@ -913,6 +916,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Dutch catalog (`crates/ui-egui/src/i18n/nl.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,447 `@msg` rows in all. The report lists 41 Dutch messages: the composite font errors, the justification spacing error and the `connect` end error, which open #7211, #7116 and #7330 translate, and three unknown-parameter and range errors (``unknown parameter `{within}{key}`; {why}``, ``unknown parameter `{within}settings.{format}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})`), which open #7353 rewrites so that a catalog row can match them. The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. A native speaker has not reviewed the wording. - The Traditional Chinese catalog (`crates/ui-egui/src/i18n/zh-hant.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,438 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 40 Traditional Chinese messages: open #7211, #7116 and #7330 add the rows for 38 of them, and the other 2 have no row in any catalog. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). + - The Brazilian Portuguese catalog (`crates/ui-egui/src/i18n/pt-br.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,563 `@msg` rows in all, including the image export, color profile, color book, swatch exchange file, DXF import and Revolve errors. The report lists 55 Brazilian Portuguese messages: the composite font, SVG filter, read root, graph design, justification order and Blob Brush connect end errors that open #7211, #7378, #7405, #7116 and #7330 translate. The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. The status bar's messages for relinked, replaced, updated and embedded images, broken links, linked files that can't be read or changed on disk, images that can't be decoded, added and unavailable fonts, copied CSS, downloads and exports with their notes name one item in the singular and more in the plural, in English and in each language's plural forms (`counted_status_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). The status bar's messages for documents opened or saved with notes and for discarded or restored recovered documents, and the Global Edit options' Results line, do the same (`open_save_and_recovery_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`); the warnings for linked images and placed documents output as their previews and the errors for a page past a placed file's last page or artboard name their counts in the singular or the plural in English. @@ -921,6 +925,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The History panel translates the undo step names the engine, the tools and the shell write as literals: the untranslated report (`untranslated_report`) lists any a complete catalog lacks, and a step whose catalog row exists only with an ellipsis (such as an effect's menu name) shows that row without the ellipsis. - The German catalog (`crates/ui-egui/src/i18n/de.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,563 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins, including the image export, color profile, color book, swatch exchange file, DXF import and Revolve errors that no other catalog translates yet. The report lists 52 German messages: the composite font and SVG filter errors that open #7211 and #7378 translate, and the justification order error that open #7116 translates. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - The Korean catalog (`crates/ui-egui/src/i18n/ko.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,438 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 39 Korean messages; they are the composite font, select-a-path, justification order and `path.freehand` connect errors that open #7211, #7214, #7116 and #7330 translate. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. + - The Swedish catalog (`crates/ui-egui/src/i18n/sv.tsv`) translates status and error messages, among them the image export, color profile, color book, swatch exchange file, DXF import, 3D and Revolve errors and the `index` check of `document.activate`, 3,106 `@msg` rows in all. The untranslated report (`untranslated_report`) lists 518 Swedish messages, 54 of them messages that no catalog translates yet. - The History panel menu has Step Backward and Step Forward, which run Edit › Undo and Edit › Redo and are disabled when there is no step to undo or redo, and Set History Limit…, which opens Preferences at Performance, where History States is set. - The History panel menu's Clear History (`edit.clearHistory`) removes every step to undo and to redo and leaves the document as it is; it is not an undo step itself, and it is disabled when there is no step to clear. - The New Document from State button at the bottom of the History panel (`edit.newDocumentFromState`) makes a new document, which becomes active, from the state the panel has selected ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/undo-edits-and-manage-design-history.html)). VectorCraft's own rules: the new document takes the next Untitled-N name, is marked unsaved, has nothing selected and has no steps to undo or redo, and the source document and its steps stay as they are. @@ -933,15 +938,17 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Perspective Distort and Free Distort (`object.distort`) turn each warped object's bounding box as the warp turns the object at its own center, the objects inside groups, compound paths and clip groups and the art of linked opacity masks included, so the next Selection or Free Transform drag uses the tilted box; Object › Transform › Reset Bounding Box squares it again, and corners left where they are add no undo step (VectorCraft's own rule; `bboxrotate::distort_turns_an_upright_box_with_the_warp_at_its_center`, `distort_turns_each_member_and_linked_mask_by_its_own_warp`, `mirrored_and_collapsing_distorts_keep_valid_angles`, `distort_angles_undo_redo_and_reopen_together`, `xform::unchanged_distort_records_no_history`). - The Free Transform widget's Constrain works in Free Distort as well as in Free Transform ([the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/free-transform-tool.html)). With Constrain on, or Shift held, a corner dragged in Free Distort moves only along the line through the opposite corner, in the box's own proportions, and the other three corners stay in place; on a turned box the line runs through the box's own corners. The line is VectorCraft's own rule. Cmd/Ctrl+Alt+Shift on a corner still drags in Perspective Distort, where Constrain stays unavailable (tools `xform::free::tests::free_distort_corner_keeps_to_its_diagonal_with_shift_or_constrain`, `shift_pressed_during_a_free_distort_drag_constrains_it`, `a_turned_box_is_constrained_along_its_own_diagonal`, `along_diagonal_of_a_degenerate_box`; engine `xform::free_transform_constrained_distort_keeps_the_corner_on_its_diagonal`; ui-egui `free_transform::tests::the_widget_sets_the_mode_and_a_corner_drag_then_distorts`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). + - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their progress rings and counts of tutorials finished; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). - - Under a tutorial's steps, View more opens the tutorial's own practice sample as a new untitled document, and once the tutorial is finished, Next Up moves to the next tutorial in the same skill, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). The samples are VectorCraft's own drawings. A Finish tutorial button that shows once the sample is open, the untitled copy, Back to skills in place of Next Up after a skill's last tutorial, and Next Up showing the next tutorial without opening its sample are VectorCraft's own rules. `learn.openSample`, `learn.finish`, `learn.next` and `learn.skills` (Back to skills) script them. Not done: the help page's short video, and progress kept between sessions and shown in the skill rings. Regression tests: `view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document`, `finishing_a_tutorial_offers_next_up_in_its_skill`, `the_practice_controls_work_from_the_keyboard`, `the_last_tutorial_of_a_skill_offers_back_to_skills`, `learn_finish_and_learn_next_follow_the_practice`, `learn_skills_shows_the_list_of_skills`, `the_practice_commands_reject_a_missing_or_unknown_tutorial`, `every_tutorial_has_its_own_readable_sample` and `the_next_tutorial_is_the_next_one_in_the_same_skill` in `crates/ui-egui/src/panels/learn/tests.rs`. + - Under a tutorial's steps, View more opens the tutorial's own practice sample as a new untitled document, and once the tutorial is finished, Next Up moves to the next tutorial in the same skill, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). The samples are VectorCraft's own drawings. A Finish tutorial button that shows once the sample is open, the untitled copy, Back to skills in place of Next Up after a skill's last tutorial, and Next Up showing the next tutorial without opening its sample are VectorCraft's own rules. `learn.openSample`, `learn.finish`, `learn.next` and `learn.skills` (Back to skills) script them. Not done: the help page's short video. Regression tests: `view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document`, `finishing_a_tutorial_offers_next_up_in_its_skill`, `the_practice_controls_work_from_the_keyboard`, `the_last_tutorial_of_a_skill_offers_back_to_skills`, `learn_finish_and_learn_next_follow_the_practice`, `learn_skills_shows_the_list_of_skills`, `the_practice_commands_reject_a_missing_or_unknown_tutorial`, `every_tutorial_has_its_own_readable_sample` and `the_next_tutorial_is_the_next_one_in_the_same_skill` in `crates/ui-egui/src/panels/learn/tests.rs`. + - A Learn tutorial's steps are marked complete as the learner works, and a skill's progress ring fills as its tutorials are finished, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). Each step has a check box that marks it done once the tutorial's sample is open (`learn.markStep`); Finish tutorial marks every step done; a skill's count shows its tutorials finished of all, and a finished tutorial has a check mark in its skill's list. Marking steps by hand, the count, keeping the progress in the preferences (the web build keeps it while the page is open) and Reset progress (`learn.resetProgress`) are VectorCraft's own rules; `learn.list` reports the progress. Regression tests: `a_step_s_check_box_marks_only_that_step`, `a_step_s_check_box_works_from_the_keyboard`, `finishing_tutorials_fills_their_skill_s_ring_and_count`, `a_skill_with_no_tutorials_shows_0_of_0`, `learn_mark_step_checks_its_params`, `reset_progress_clears_only_the_progress`, `the_preferences_keep_the_progress` and `saved_progress_is_read_leniently` in `crates/ui-egui/src/panels/learn/tests.rs`. - The Home screen has a Learn section with the in-app tutorials, as the help page lists among the Home screen's options ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/homescreen-overview.html)): the Quick skill guide card shows the Learn panel's list of skills (`learn.skills`), and each tutorial, listed under its skill, shows the panel at its steps (`learn.show`). It shows with no document open and over open documents, and a choice leaves the documents as they are. Its place between Recent Files and the new-file presets, its layout and what a choice opens are VectorCraft's own rules (`tests::home::home_has_a_learn_section_that_opens_the_learn_panel`, `home_learn_section_over_open_documents_leaves_them_as_they_are`, `home_quick_skill_guide_card_works_from_the_keyboard`). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the 3D effects (Extrude & Bevel, Revolve, Inflate, Rotate and their Classic forms) show in the status bar in the interface language: the art a 3D effect cannot build a solid from, invalid light, graphics and surface settings, and the notes for an object that draws no 3D faces or more wireframe edges than the limit. Each has a row in every catalog that translates all status and error messages, and the effect's name and the values in the message are translated or kept as written (`three_d_refusals_are_translated_in_every_complete_catalog`, `three_d_refusals_translate_with_their_values`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). - A parameter schema's errors show in the status bar with what the value must be in the interface language, for every type a schema has (a number with each kind of range and with a unit, an integer and its range, true or false, a string, a list of names, an object id, a list of object ids, a color, a point, an object and a list), in every catalog that translates all status and error messages; the path, the refused value, the bounds, the unit and the names stay as the English gives them. The error for a value that is not one of its names is also translated in Bulgarian, Bosnian, Danish, Indonesian and Dutch (`parameter_schema_expected_phrases_are_translated`, `bracketed_message_parts_translate_only_as_exact_messages`). - The untranslated report lists the status and error messages of the effects, swatch libraries, raster sizes and encoders, 3D solids and path operations that a catalog lacks, the messages that a `.map_err` closure or a named error handler returns, and the phrases a parameter schema's error builds for what a value must be (`untranslated_report_checks_every_message_crate_and_schema_phrase`, `message_scan_reads_map_err_closures`, `message_scan_reads_named_map_err_handlers`). + - Spanish messages (2026-11-13): the Spanish catalog (`crates/ui-egui/src/i18n/es.tsv`) has an `@msg` row for 114 of the 167 status and error messages that the untranslated report (`untranslated_report`) lists as missing in all 23 complete-message languages: the errors of the image encoders, color profiles, swatch exchange files and color books, 3D and Revolve, path operations, DXF import, the script engine and the parameter checks. Open #7211 and #7378 translate 51 of the others (the composite font and SVG filter errors), and 2 stay in English. The rows keep the placeholders, backticked names, accepted values and limits of their English (`spanish_translates_encoder_profile_swatch_revolve_and_parameter_errors`); native review remains; scores and estimates unchanged. - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and warn about a key that only another format takes (`{"format": "pdf", "quality": 80}` writes the PDF with the warning "`quality` was not used: PDF files don't read it"; `an_option_only_another_format_reads_gets_a_note`, `control_export_notes_an_option_the_format_does_not_read`, `convert_warns_about_an_unused_option_only_when_one_was_given`), and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. - Save commands run through MCP, the control channel or the CLI (`document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `app.save`) refuse a key that no save format takes, beside the path or in `options`, and warn about a key the file doesn't read, beside the path or in `options`, with an export's `.ai` rules; Save Options sends a `.ai` save only the options the chosen version reads (`a_save_notes_the_ai_options_its_file_does_not_read`, `ai_save_options_send_only_the_options_the_version_reads`). Export for Screens refuses a key it doesn't take, and a key that no format takes in a format row or in a format's settings, by its path (`formats[1].marks.useDocumentBleed`), and warns about a key there that only another format takes (`a_row_option_only_another_format_reads_gets_a_note`); Export Assets (`assets.export`, `assets.settings.set`) checks the rows and settings it is given the same way. - Commands that read their `ids` (or `slices`) list themselves refuse a list with an entry that is not a non-negative integer, and a value that is not a list, and change nothing; they no longer drop the entry and act on the rest (Copy Appearance From, the Edit Colors commands and Overprint Black, Find and Replace, `layer.highlight`, `layer.move`, `library.add`, the Links panel commands, `object.transform`, Make Pattern, the Repeat commands, the slice commands, Join, Average and Join, `plugin.run`, `symbol.edit`, Make Opacity Mask; `tests::ids_without_selection::an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing`). The error for an entry of an object id list names the key, the entry and its value ("`ids` entry 1 is true, not a non-negative integer object id"), with a row in every catalog that has the other object id errors. @@ -1137,6 +1144,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). + - **Blend steps of spot gradients with process plates:** when the matching stops of two gradients, or of a gradient and a solid, are tints of one spot swatch, each step's stop stays linked to it at a tint between the two and keeps the process inks printed with it, each of C, M, Y and K between the two stops' amounts; a stop or solid without process inks counts as none of each. Gradients with different stop counts keep the inks at every resampled stop. PDF export writes such a step as one DeviceN shading of the spot and process inks. Tints of one spot color blend into tints of it, as [the help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; the process-ink interpolation is VectorCraft's own rule (`crates/doc/src/blend.rs`, `blend_gradient_spot_process_plates_interpolate`, `resampled_gradient_spot_process_plates_interpolate`, `blend_gradient_missing_plates_mean_zero`, `blend_solid_to_spot_gradient_does_not_copy_plates`; `crates/pdf/src/tests/output.rs`, `blend_steps_between_spot_gradients_with_process_plates_print_both_inks`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, a mark's object opacity and blend mode, and the opacity, blend mode, isolation and knockout of a series group picked with Group Selection, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo; a kept gradient takes the same place on each mark's own box. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, or recolored, given Swap Fill and Stroke or Default Fill and Stroke, or given stroke options or a gradient, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. @@ -1158,6 +1166,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Space held while the Zoom tool drags a marquee moves the marquee and keeps its size ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). When Space is let go, the drag sizes the marquee again from where it moved to. A Space+Shift drag rotates the view whatever the tool, as a Rotate View tool drag does, and Shift does not round the angle to 15°. While the Type tool edits text, Space types a space: held, it does not show the hand, pan or rotate the view, and Cmd+Space still zooms ([the help page](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) lists the Spacebar as the Hand tool when not in text-edit mode). Test: `canvas::tests::space_types_a_space_while_editing_text_and_does_not_pan`. + As VectorCraft's own rule, a zoom marquee that Cmd+Space starts while the Type tool edits text keeps the marquee's Space rules after Cmd is let go: Space held since the press sizes it, Space pressed again moves it ([the help page](https://helpx.adobe.com/illustrator/using/viewing-artwork.html) describes holding the spacebar to move the marquee), and that Space types no space, nor does it during an animated zoom drag. With no zoom drag, Space types a space. Test: `canvas::tests::space_moves_a_text_edit_zoom_marquee_after_cmd_is_let_go`. - **Swatches panel clicks:** Ctrl/Cmd-clicking New Swatch makes a spot color, and Ctrl/Cmd+Shift-clicking it makes a global process color. - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, art dragged onto the Swatches panel becomes a pattern swatch, and art dropped on the Brushes panel opens New Brush for that art; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. New Brush asks for the type of brush first, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. The Brushes panel's New Brush button, its menu's New Brush… and art dropped on the panel open the New Brush dialog, where Scatter Brush, Art Brush and Pattern Brush need selected or dropped art. OK opens that type's Brush Options for the new brush, and their OK makes the brush with the name and options set; Cancel in either dialog adds no brush (`ui.newBrush`). The menu's New Calligraphic Brush, New Bristle Brush and New Art, Scatter and Pattern Brush from Selection items still make a brush at once. @@ -1171,7 +1180,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In the Pencil and Paintbrush Tool Options, Edit selected paths within 0 px turns editing off, so a stroke that starts exactly on an end of a selected open path draws a new path; Close paths when ends are within 0 px turns closing off, so a stroke that ends exactly where it started stays open. The Pencil's and the Paintbrush's Fidelity is in screen pixels, the same at any zoom: their Tool Options give it in px, from 0.01 px on the logarithmic slider to 20 px and up to 100 px in the field, and the tool sends `path.freehand` the value in points at the current zoom (#5729). The Smooth tool's and the Blob Brush's Fidelity stays in points. `path.freehand` and `brush.freehand` take a Fidelity of up to 100 / 0.0313 pt (about 3194.89 pt), the 100 px field maximum at the lowest zoom (3.13 %), and reject a value outside their range with an error, so a Pencil or Paintbrush stroke drawn zoomed out keeps the Fidelity set in pixels (engine tests `pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out` and `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`). + `brush.freehand` refuses a `brush` that names no brush in the document, or is not a string, and a `style` other than "pencil" or "brush", with an error and no change or undo step, instead of drawing the plain path; the Paintbrush skips a chosen brush that has since been deleted (engine tests `brush_freehand_refuses_a_brush_or_style_it_cannot_use` and `paintbrush_tool_skips_a_deleted_brush`). Each drag of the Smooth tool smooths the stretch of a selected path in its reach along its length before refitting it within Fidelity, so a wobble wider than Fidelity is smoothed out and another drag over the same place smooths it further, as [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) describes (`path.smoothRegion`). The smoothing spread (a third of the reach) and the rule that each refit stays within twice the distance the smoothing moved the path, so repeated drags do not drift from it, are VectorCraft's own. A drag over the whole of a closed path smooths it across its first anchor too, so a bump there is smoothed like any other and no corner is left; Object › Path › Smooth smooths a closed path across its first anchor the same way. + When a Smooth tool drag reaches the first anchor of a closed path, the smoothed path starts at its point nearest where that anchor was: at an anchor there, or at a new anchor split into the curve without changing its outline. Dashes and type on the path start from that point. A drag that does not reach the first anchor leaves it first. This is VectorCraft's own rule (engine tests `smoothing_over_a_closed_paths_first_anchor_keeps_its_start` and `smoothing_a_whole_closed_path_with_a_spike_keeps_its_start`). The Smooth tool shows the Not Allowed cursor while no path is selected that it can act on (an editable path, not a guide, with three or more anchors on one of its subpaths or two anchors joined by a curve), and the release of a drag then sends no command and changes nothing; [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) has the path selected first. A selected path anywhere on the canvas gives the Smooth cursor wherever the pointer is. The Pencil with Alt key toggles to Smooth Tool on and the Blob Brush show Not Allowed the same way when Alt gives them the Smooth tool; the Paintbrush keeps its closing cursor, since its release with Alt closes the stroke. Use Precise Cursors leaves Not Allowed as it is. This cursor rule is VectorCraft's own (tools `smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `borrowed_smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `smoothing_release_sends_nothing_without_a_selected_path_to_smooth`; engine `use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer`). When a Smooth tool drag reaches the first or last anchor of an open path, that anchor stays where it is and the curve there leaves or arrives along the smoothed path within the reach, so a jagged first or last segment no longer sets the end's direction; an end where the smoothed path turns back by 90° or more keeps its segment's direction, and where the refitted stretch ends inside the path the curve keeps the direction of the part outside the drag. This rule is VectorCraft's own (`tests/draw2.rs`, `smoothing_an_open_path_turns_its_jagged_ends_with_the_path` and `a_smooth_drag_turns_only_the_end_it_reaches`). A Smooth drag along a curved segment smooths the stretch of it in the drag's reach even when neither of the segment's anchors is in reach, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-paths.html) describes dragging along the path segment; the segment is cut where the drag enters and leaves the reach, and the parts outside keep their shape (`path.smoothRegion`). Paths of two anchors are smoothed too, and Alt with the Paintbrush smooths a selected two-anchor curve; a two-anchor straight line still gets the closed stroke. How the reach is found on a segment (samples no more than half the reach apart) is VectorCraft's own, and a straight segment is left as it is. @@ -1273,6 +1284,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Revolve and Revolve (Classic) draw two overlapping faces of a surface in the order of their depths where they overlap, instead of by the depths of their centers, on one object and across a group's members, so a tilted profile with long edges or a profile of several contours no longer draws a nearer face under a farther one. A surface whose faces were already in the right order keeps it. Tests: `solids::tests_revolve_order::a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last`, `solids::tests_revolve_order::a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last`, `solids::tests_revolve_order::a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last`, `solids::tests_revolve_order::a_surface_already_in_order_keeps_it`, `solids::tests_revolve_order::surface_order_puts_the_nearer_face_where_they_overlap_last`, `tests::a_revolved_face_measures_its_depth_on_its_plane`. - Under a colored light, an Inflate fill and strokes in solid colors, opaque with Normal blending, also draw their faces in that one order, inside a group member's own opacity mask too, instead of each solid as one composite with the stroke's faces over every fill face. Each face keeps the composite's paint, light ramps and seam width; a lone solid, the solids of an object with a translucent or blended fill or stroke, and such solids holding more than 30,720 faces together (VectorCraft's own limit) are still drawn as one composite each. Tests: `solids::tests::colored_solid_fills_and_strokes_share_face_depth_order`, `solids::tests::a_colored_solid_face_on_its_own_keeps_the_composites_colors`, `solids::tests::colored_solid_inflate_batches_channels_and_clips_once_per_surface`, `solids::tests::colored_solids_past_the_face_limit_draw_as_composites`. - Without a twist, a tapered Extrude & Bevel wall (and each bevel band) is one flat face; only a twist cuts walls into slices, so no seam between slices paints a light tick or a light line on a tapered, untwisted wall (#7019). Test: `solid::tests::a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced`. + - Extrude & Bevel cuts a corner square where the bevel opens it and its miter would reach past four times the width (a sharp point with the bevel outside, a narrow notch with it inside), so the bevel (and, with the bevel outside, the walls) no longer runs out to a spike that does not line up with the art (#7525). Tests: `solid::tests::a_bevel_cuts_sharp_corners_square`, `solid::tests::a_cap_draws_no_empty_segment_at_a_corner_cut_square`. - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). @@ -1320,6 +1332,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`. - **Manual kerning on inline graphics:** a manual kerning value on an inline graphic's character spaces it from the next character in reading order by the same distance as on a letter of its run's size, in left-to-right, right-to-left and vertical type; Auto and Optical kerning still apply no font pairs to it. This is VectorCraft's own rule. Test: `manual_kerning_on_an_inline_graphic_spaces_it_like_a_letter`. - **Inline graphics in symbol art follow their symbols:** when a symbol changes, type with an inline graphic of it is laid out again wherever the type is: in the layers, in the art of other symbols, graph designs and pattern tiles, and in opacity masks. A symbol's type is laid out before the type that shows that symbol inline, so a symbol whose art holds an inline graphic, its instances and the inline graphics that show it take its new size. Opening and reverting a document and Refresh Font List resolve them in the same order. An inline graphic of a symbol whose art shows that symbol inline, directly or through other symbols, is an empty slot. This is VectorCraft's own rule. Tests: `inline_graphics_in_symbol_art_follow_their_symbol`, `inline_graphics_in_masks_and_pattern_tiles_follow_their_symbol`, `symbols_are_resolved_after_the_symbols_they_show_inline`, `a_symbol_that_shows_itself_inline_is_an_empty_slot` in `crates/engine/src/tests/inline.rs`. +- **Inline graphics of symbols that hold other symbols:** an inline graphic of a symbol whose art holds an instance of another symbol is sized by that symbol's own art, placed by the instance, and follows it when that symbol changes. A symbol's type is laid out after the type in the art of the symbols its instances show. An inline graphic of a symbol whose art reaches that symbol through an instance and an inline graphic is an empty slot; an instance that does not size the art, such as one a clipping path hides, does not count. An instance of a missing symbol, of a symbol without art, of a symbol that holds an instance of itself, or of a symbol whose art nests symbols 32 deep is measured by the 20 × 20 square. This is VectorCraft's own rule. Tests: `an_inline_graphic_follows_the_art_of_a_symbol_nested_in_its_symbol`, `symbols_are_resolved_after_the_symbols_their_instances_show`, `a_symbol_that_shows_itself_through_an_instance_is_an_empty_slot` in `crates/engine/src/tests/inline.rs`; `a_symbol_nested_in_symbol_art_is_measured_by_its_own_art`, `nested_instances_without_art_of_their_own_are_measured_by_the_square`, `only_instances_that_size_a_symbols_art_make_it_depend_on_their_symbol` in `crates/doc/src/inline.rs`. - **Recent font pickers (#3515):** The shared Character, Properties, Glyphs and Find Font family picker lists recently applied families first, once each, in most-recent order and limited by Preferences › Type › Number of Recent Fonts. Search and filters apply to both sections; unavailable families are omitted. Section headings cannot be selected or previewed. Confirmed character-range font edits update the same saved history; previews and failed edits do not. Requirement: [issue #3515](https://github.com/storytold/vectorcraft/issues/3515). - **Show samples on white (a VectorCraft addition):** Preferences › Type › Show samples on white (`fontSamplesOnWhite`, off by default) draws each family's sample in every font menu, and in Type → Font in the in-window menu bar, in dark type on a white area in every interface brightness. A highlighted row's color shows above and below the white area, and the names and the rest of the menu keep the interface's colors. The checkbox is dimmed while Enable in-menu font previews is off. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/src/prefs_dialog/tests.rs`. - **Find Font: Replace With Font From:** a dropdown under Replace With Font chooses the list the replacement font comes from: Document lists the families the document uses, once each; Recent lists the recently applied families that are installed (fonts applied with Find Font's Change and Change All included), newest first, as many as Preferences › Type › Number of Recent Fonts allows; System, the default, lists the installed families available in the font menu. The dialog opens on System with Source Sans 3 chosen, or the font menu's first family when Source Sans 3 is not installed. Choosing a list keeps the chosen font when the list has it and it is installed; otherwise Document and Recent choose the list's first installed font, and System chooses Source Sans 3 or the font menu's first family. Change and Change All are unavailable when the list has no installed font (Recent before any font was applied) and when the chosen family is not installed or is not in the shown list. Behavior reference: [Find and replace fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) (public documentation). Tests: `crates/ui-egui/src/find_font.rs`. @@ -1345,7 +1358,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. - On a shaped area type frame, Inset Spacing keeps text that far from the frame's edge, holes included, measured from the edge itself rather than along the line; the archived help treats the inset as the space an area type object leaves between its text and the path that bounds it (<https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=317>, <https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=325>). A rectangular frame keeps its inset from each side as before, and a column's inner edges keep it once. Tests: `layout::region::inset_on_a_shaped_frame_is_measured_from_its_sloped_edge`, `inset_keeps_text_clear_of_a_hole_s_sloped_edges`, `inset_applies_once_at_a_column_s_edge_and_at_the_frame_s_edge`. + On a shaped area type frame, Inset Spacing keeps text that far from the frame's edge, holes included, measured from the edge itself rather than along the line; the archived help treats the inset as the space an area type object leaves between its text and the path that bounds it (<https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=317>, <https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=325>). A rectangular frame keeps its inset from each side as before, and a column's inner edges keep it once. An edge that two contours of the frame share lies inside the frame, so text runs across it with no inset on either side, and a negative text wrap offset does not shrink a wrap object beside such an edge. Tests: `layout::region::inset_on_a_shaped_frame_is_measured_from_its_sloped_edge`, `inset_keeps_text_clear_of_a_hole_s_sloped_edges`, `inset_applies_once_at_a_column_s_edge_and_at_the_frame_s_edge`, `inset_is_not_kept_beside_an_edge_two_contours_share`, `negative_wrap_offset_does_not_shrink_beside_an_edge_two_contours_share`. An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). When a story flows through threaded area type, each frame is measured with the whole paragraphs of the story that it needs and a few more, not with the whole rest of the story, and it keeps the same text as before. A story of 1,500 one-line paragraphs (55,892 bytes) through 40 frames was laid out over 2,126,762 bytes of text while the frames were measured, and is now laid out over 21,035 (`thread::tests::many_frames_measure_about_the_text_they_take`). Area type with its lines aligned to the center, the bottom or justified vertically, and type on a path whose first paragraph is aligned Left, Justify Left, Justify All or Auto, are measured the same way: the same story through 40 area frames aligned to the center was laid out over 2,126,762 bytes and is now laid out over 21,035, through 40 paths aligned Left over 2,135,202 bytes and now over 20,583, and through 40 paths aligned Justify All over 2,135,202 bytes and now over 20,583 (same test). Shrink Text to Fit, Dynamic Text, Optical Margin Alignment and Roman Hanging Punctuation on a path, a path aligned to the center or the right, horizontal type on a path whose first paragraph is set right to left or with right-to-left text in the rest of the story, and a frame whose share ends in the story's last paragraph are still measured with the whole rest. A paragraph that continues from one threaded frame into the next has its first-line indent and bullet or number in the frame where it starts; in the next frame its first line starts where its wrapped lines start. In frames that are no longer threaded, copies of a frame and point type converted from one, the first paragraph has its indent and bullet or number again. @@ -1361,7 +1374,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Flatten Transparency takes a frame of threaded text out of the thread in the same way when Convert All Text to Outlines outlines it or when the frame becomes flat regions or an image, and the other frames keep the text they hold. Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them. Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency leave art without transparency as it is when it overlaps only other art without transparency, unless it overlaps a member of a see-through object's group and lies above that member and below the group's topmost object. Such art joins the group, and the group takes the place of its topmost object, so the stacking order stays the same. In files without transparency with Clip Complex Regions on, art under the group within one image pixel of a member joins it too. The 1 pt antialiasing margin goes around a group's art once, however deeply its groups nest. On a grid of 3,000 rectangles with a 0.5 pt stroke, 60 of them with a drop shadow, the Flattener Preview counts 180 affected objects where it counted 3,000, and Flatten Transparency with the Medium preset makes 240 flat regions and 2 images where it made one image of the whole grid. These rules are VectorCraft's own (`tests::flatten::opaque_art_that_touches_only_opaque_art_stays`, `tests::flatten::opaque_art_the_group_would_move_under_joins_it`). - When they group art for flattening, Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency compare the box of an object without transparency only with the boxes of see-through objects in the pass that splits art by transparency. In both passes, a box that lies inside an earlier box that ends no further right and joins everything it overlaps is not compared with later boxes. With one see-through object inside 512 nested objects without transparency, the grouping compares 1,024 pairs of boxes where it compared 262,145, and the groups stay the same (`cmd::flatten::analysis::tests::nested_opaque_boxes_meet_only_the_see_through_box`, `a_shrinking_nest_meets_only_its_outermost_box`). + When they group art for flattening, Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency compare the box of an object without transparency only with the boxes of see-through objects in the pass that splits art by transparency. In both passes, a box that lies inside an earlier box that ends no further right and joins everything it overlaps is not compared with later boxes. With one see-through object inside 512 nested objects without transparency, the grouping compares 1,024 pairs of boxes where it compared 262,145, and the groups stay the same (`cmd::flatten::analysis::tests::nested_opaque_boxes_meet_only_the_see_through_box`, `a_shrinking_nest_meets_only_its_outermost_box`). When a later box that joins everything it overlaps covers an earlier box to the right and below, the grouping compares the earlier box with still later boxes only above the later box's top: 512 boxes, each 1 pt right of and below the one before, compare 511 pairs where they compared 130,816, with and without transparency, and the groups stay the same (`cmd::flatten::analysis::tests::staggered_boxes_meet_only_the_box_before`, `staggered_boxes_group_as_comparing_every_pair`). New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. Type › Type on a Path › Center on Top and Center on Bottom (`type.pathCenter.top`, `type.pathCenter.bottom`, also on the Contextual Task Bar for type on a closed path) slide type on a closed path so the middle of its span lands on the path's topmost or bottommost point as drawn, keep the span's length, and set the first paragraph to Center unless it is Center or Justify Center. This is VectorCraft's own behavior (`crates/geom/src/arc.rs`, `crates/engine/src/tests/pathtype.rs`). @@ -1403,7 +1416,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. With the Selection tool, the Tabs panel shows the ruler and stops of the first paragraph of the first selected text object, also when the text is inside a selected group or a shape is selected before it; a selection without text shows "No text selected". A stop set on that ruler is set on every selected text object and the text in selected groups, and other objects are left as they were. This is VectorCraft's own rule. Tests `panels::tabs::tests::the_ruler_shows_text_selected_after_an_object_that_is_not_text`, `the_ruler_shows_text_inside_a_selected_group`, `a_stop_added_on_a_mixed_selection_sets_every_selected_text` and `a_selection_without_text_shows_no_ruler`. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. -- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Finnish and Dutch also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). +- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Georgian, Hindi, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Finnish, Turkish, Catalan and Dutch also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). With Line-end Punctuation Half Width, both composers measure a line with its last character set as narrow as Mojikumi sets it ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html)): a closing bracket, comma or full stop that fits at half width ends the line, and the character before it no longer goes to the next line with it. A mark that already lost its half to the punctuation after it and a line that ends in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends there. Kinsoku Shori Type and Burasagari Regular measure lines the same way; right-to-left lines and Mojikumi None break as before. In a frame that is not a rectangle, or with text wrap, a line's first word is measured as it is set (an opening bracket at its start flush, its last character narrowed) when layout looks for a line wide enough for it, and it ends only where the line may break, so kinsoku keeps an opening bracket with the character after it and a No Break run counts whole (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/vertical.rs`). Scores and estimates are unchanged. The Paragraph panel's Mojikumi Set lists the five predefined sets the help page names (None, YakumonoHankaku, GyomatsuYakumonoHankaku, GyomatsuYakumonoZenkaku and YakumonoZenkaku), then Line-end Punctuation Half Width, and `text.setFormat {mojikumi}` takes each of them. YakumonoHankaku sets every bracket, comma and full stop half width and takes a quarter em off each side of a middle dot; GyomatsuYakumonoHankaku keeps punctuation full width inside a line, sets an opening bracket flush at a line's start and a closing mark half width at its end, and takes the quarter em after a middle dot off at a line's end; GyomatsuYakumonoZenkaku keeps punctuation full width and adds a quarter em between Japanese and Latin text; YakumonoZenkaku sets characters as None does. These widths are VectorCraft's own rules, from the help page's one-line description of each set. Both composers and vertical type measure each line as it is set, the sets save with the document and paragraph styles, and a `.ai` save reports the new sets as left out (`crates/text/src/tests/vertical.rs`, `crates/ui-egui/src/panels/paragraph/tests.rs`). Scores and estimates are unchanged. Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). @@ -1422,6 +1435,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, curve fitting tolerance:** the outline fitter keeps cutting a run while a point stays farther than the tolerance from its curve, so a run of fewer than six points or one cut 14 times no longer keeps a piece over the tolerance. Pieces already within the tolerance do not change, and noisy art can get more anchors. Tests: `logo::bezier::tests`, `logo::outline::tests`. Scores and hours unchanged. - **Image Trace, gray photos:** Color and Grayscale smooth a gray photo within its tones before choosing its colors, as they do a color photo. A picture with at least 95% of its opaque pixels within 8 levels across their channels counts as continuous tone when 8 colors leave an error of more than 6 levels and more than 0.065 of the error of one color, instead of 0.1, unless 16 colors leave at most a fifth of the error of 8 (VectorCraft's own values). That exception keeps gray flat art of up to 16 clean levels on its old route; anti-aliased or noisy gray flat art can now count as continuous tone too. Most gray photos the change reaches, and very noisy gray scans of flat art, trace with fewer specks and fewer staircase edges. Some 16 Colors and High Fidelity Photo palettes of gray photos grow toward the requested count, with more anchors and regions. Some gray illustrations and sketches get more anchors and staircase edges at 16 Colors and High Fidelity Photo, and some 6 Colors traces get more staircase edges. Tests: `tests::a_gray_photo_is_continuous_tone_as_its_color_original_is`, `tests::a_gray_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. - **Image Trace, tinted photos:** Color and Grayscale smooth a sepia, single-hue or duotone photo within its tones before choosing its colors, as they do a gray photo. A picture with at least 95% of its opaque pixels within 6 levels of its tone curve counts as continuous tone under the same 0.065 rule as a neutral picture. The tone curve runs through the median color of each 8-level stretch along the direction in which the picture's colors spread the most, and a pixel on it must also lie within 6 levels of the plane the colors spread along most, so art in several unrelated colors is not counted (VectorCraft's own rule and values). The neutral rule's exception for 16 colors leaving at most a fifth of the error of 8 applies to it too. Clean tinted bands of 2, 3 and 9 levels and tinted scans of flat art with up to 8 levels of noise stay on the flat-art route. On the synthetic tinted photos the 16 Colors trace keeps 12 to 15 colors instead of 6 to 13, with 525 to 708 anchors instead of up to 1,402. A duotone with strong noise on each channel can still miss the curve and stay unsmoothed. Tests: `tests::a_tinted_photo_is_continuous_tone_as_a_gray_photo_is`, `tests::small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone`, `tests::tinted_flat_art_stays_flat_art`, `tests::pictures_without_tones_are_not_continuous_tone`, `tests::a_tinted_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. +- **Image Trace, flat art of 10 to 16 levels:** the exception for 16 colors leaving at most a fifth of the error of 8 also applies to a neutral or tinted picture whose 8-color error is more than 0.1 of the error of one color (VectorCraft's own values), so Color and Grayscale trace clean gray, sepia, single-hue and duotone art of 10 to 16 evenly spaced levels as flat art, without smoothing it first. At 16 Colors, 16 gray or sepia levels crossed by two 1 px lines trace as the 35 regions the lines cut them into instead of 33, and Automatic traces 16 clean sepia levels in 9 colors instead of 4. Tests: `tests::flat_art_of_up_to_sixteen_clean_levels_stays_flat_art`, `tests::thin_lines_on_flat_art_of_up_to_sixteen_clean_levels_are_traced`. Scores and hours unchanged. - **Image Trace, JPEG blocks:** Color, Grayscale and Black and White traces smooth the edges of a JPEG's 8 × 8 blocks before tracing, so a dark ground whose tone changes slowly is no longer traced as staircases along the block edges. The block grid is found in the image itself: the mean step between neighboring pixels must peak at one position in eight, across and down. A cropped JPEG and a JPEG saved in another format are smoothed too. A picture of flat 8 × 8 cells is left as it is, and art made of 8 × 8 cells with slight texture inside each cell is smoothed as a JPEG copy would be. A small step at a block edge, with no edge in the eight pixels around it, is spread over those eight pixels, and larger steps are kept, as the deblocking filters of ITU-T H.263 Annex J and MPEG-4 Part 2 Annex F.3.1 do. Black and White smooths only the smallest steps, and traces of more than 16 colors move every row of a stretch of eight by the same amount (VectorCraft's own rule and values). On a 4000 × 3000 photo saved as JPEG, the straight outline segments that lie on the block grid fall from 52% and 51% (across and down) to 24% and 29% at 16 Colors, and from 54% and 51% to 43% and 42% at High Fidelity Photo; staircase runs fall from 83 to 67 (16 Colors), 11 to 1 (6 Colors), 30 to 13 (Black and White Logo) and 428 to 403 (High Fidelity Photo). A 600 px photo saved as a quality-40 JPEG has 4 staircase runs instead of 2 at 16 Colors. Images without a block grid trace as before. Tests: `tests::a_jpegs_blocks_are_not_traced_as_steps`, `deblock::tests`. Scores and hours unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. - **Image Trace, Document Library colors:** with Document Swatches, traced fills and strokes use the displayed colors of process, global, spot and tint swatches without links, so editing a swatch leaves the trace unchanged; swatch-library colors are also unlinked; code-drawn tests in `crates/engine`. @@ -1435,6 +1449,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, Flat Logo working size:** Flat Logo traces at most 1024 px on a side; a larger decoded raster (the 64-megapixel copy for a bigger image) is averaged down in whole-number blocks first, and the trace's `warnings` name the size it was traced at and the image's own size, in place of the slower-trace and 64-megapixel notes; code-drawn 1024 and 1025 px tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo working size:** Flat Logo traces at most 1024 px on a side; a larger image is averaged down by a whole factor first, and the trace's `warnings` name the size it was traced at and the image's own size, in place of the slower-trace and 64-megapixel notes; code-drawn 1024 and 1025 px tests in `crates/trace` and `crates/engine`. - **Image Trace, Preview:** the Image Trace panel's Preview checkbox, before Trace, is on by default, and a setting change traces the selected Image Trace object again at once. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected. When Preview is turned on with that object not selected, the object is traced with its changes when it is selected again. Preview is kept with the UI preferences, and Reset to Default leaves it. The `imageTrace.preview` command turns it on or off. +- **Image Trace, traces made off the UI thread stay with their document:** a trace made away from the command (`TraceRun`) applies only in the document it was made in. In another open document with the same image, object and settings, `imageTrace.make` and `imageTrace.makeAndExpand` drop it and trace that document's image, as one undo step (VectorCraft's own rule). Test: `a_trace_run_applies_only_in_its_own_document` in `crates/engine`. Scores and hours unchanged. - **Image Trace small contours:** closed fits too small to form an editable subpath retain their positioned outline, including holes. Source-generated island, hole/winding and stepped-region regressions cover Corners and Noise; defaults, parity scores and estimates are unchanged. - **Image Trace, custom presets:** Manage Presets next to the Image Trace panel's Preset dropdown saves the panel's settings as a new preset under a name (Save as New Preset…) and renames or deletes the saved preset shown; built-in presets don't change. Saved presets are kept with the preferences and follow the built-in ones in the panel, Control bar and Properties preset lists. `imageTrace.presets.save` and `imageTrace.presets.delete` save, rename and delete them, `imageTrace.presets` lists them, and `imageTrace.make {preset}` traces with one by name. - **Image Trace, preset buttons:** six buttons above the Image Trace panel's Preset dropdown choose a preset as the dropdown does, for the options the [help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/image-trace-panel-options.html) lists before Preset. The presets each button chooses are VectorCraft's own: Auto Color chooses Auto Color, High Color chooses High Fidelity Photo, Low Color chooses Low Fidelity Photo, Grayscale chooses Shades of Gray, Black and White chooses Black and White Logo, and Outline chooses Outline. With Preview on, a click traces the selected Image Trace object again as one undo step; with nothing traced selected, it changes the panel's settings only. The button of the panel's preset shows as selected. @@ -1458,6 +1473,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo flat-art check with named colors:** with `logoColors` named, whether the image is flat art is judged by its own colors, as many as are named, with white, or as the Colors setting gives, whichever is more. Named colors 20 levels off a flat badge's colors still trace it, a hatch on a light ground gives the same result with close and far named colors, a gradient's refusal score does not depend on how far the named colors are from it, and areas of colors that were not named are traced in the named colors instead of being refused; code-drawn tests in `crates/trace`. +- **Image Trace, Flat Logo with a color between two others:** an anti-aliased edge pixel between black and white is read as a blend of black and white, not of black and a gray that lies between them, when the flat areas around it and the pixels beside it show that edge, so a black disc beside a gray square on white, with sharp edges or edges blurred by up to about half a pixel, traces with one gray path, the square, instead of the square plus 8 to 24 gray slivers around the disc with the colors found or named exactly (an edge blurred by a pixel or more, or pixel noise of a level or more, can still leave gray slivers); gray strokes two and three pixels wide beside black stay gray (a soft-edged gray stroke about two pixels wide that touches black traces as black and white, and a short one about three pixels wide can lose most or all of its gray), and a one-pixel gray line of at least 13 touching pixels between black and white stays gray (a shorter one, or one that falls between pixels or is blurred, traces as black and white); code-drawn tests in `crates/trace`. - **Image Trace, Keep Colors:** `"keepColors": ["#rrggbb", …]` traces only the palette colors near a listed color (Abutting: each with the paths the trace of every color gives it; Overlapping: not reaching under the colors left out), in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; an image that isn't turned or skewed, isn't CMYK and can be read at its recorded size is cut from its own pixels when the crop edges fall on its pixel grid, copied as they are without resampling; any other image is drawn, #5534; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). @@ -1502,7 +1518,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, seven gradient libraries (among them Organic Fades, soft perceptually mixed multi-stop fades that thin out to transparent, and Holographic, seven opaque linear foils of narrow pastel and pearl bands and seven radial glows that fade out to a clear edge) and three pattern libraries (Geometric Patterns; Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; and Optical Effects with eight of VectorCraft's own seamless wave, ring, warped-check, stripe, chevron and lens tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. - A swatch step in `command.batch` (Delete, Swatch Options, Merge, Edit Color Group) changes the fill and stroke for new art as the command run on its own does, so art drawn later in the batch or after it takes the same paint; a batch that fails, and a canceled batch preview, put back the paints it started with, and a canceled Swatch Options preview leaves them as they were. These swatch commands also change the fills and strokes of the appearance new art takes, and Undo and Redo of the command or the batch put the paints for new art back with the swatches. Evidence: `swatch_steps_in_a_batch_change_the_new_art_paints_as_commands_do`, `undo_and_redo_of_a_swatch_edit_put_back_the_new_art_paints`, `swatch_edits_reach_the_paints_of_the_new_art_template`, `a_failed_batch_puts_back_the_new_art_paints_a_swatch_step_changed` and `canceling_a_swatch_options_preview_keeps_the_new_art_paints` in `crates/engine/src/tests/swatches.rs`. + A swatch step in `command.batch` (Delete, Swatch Options, Merge, Edit Color Group) changes the fill and stroke for new art as the command run on its own does, so art drawn later in the batch or after it takes the same paint; a batch that fails, and a canceled batch preview, put back the paints it started with, and a canceled Swatch Options preview leaves them as they were. These swatch commands also change the fills and strokes of the appearance new art takes, and Undo and Redo of the command or the batch put the paints for new art back with the swatches. Spot Colors options (`swatch.spotOptions`) and Default Swatches with replace (`swatch.resetDefaults {replace: true}`) as `command.batch` steps also change the fill and stroke for new art as each command run on its own does: a fill or stroke linked to a Lab spot swatch keeps its link and tint and takes the Lab values or their CMYK equivalent, and one linked to a swatch the replace removes keeps its color and loses the link. Evidence: `swatch_steps_in_a_batch_change_the_new_art_paints_as_commands_do`, `spot_colors_options_in_a_batch_change_the_new_art_paints_as_the_command_does`, `default_swatches_replace_in_a_batch_unlinks_the_new_art_paints_as_the_command_does`, `undo_and_redo_of_a_swatch_edit_put_back_the_new_art_paints`, `swatch_edits_reach_the_paints_of_the_new_art_template`, `a_failed_batch_puts_back_the_new_art_paints_a_swatch_step_changed` and `canceling_a_swatch_options_preview_keeps_the_new_art_paints` in `crates/engine/src/tests/swatches.rs`. Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. The prompt for deleting several swatches and groups uses the interface language's plural form for the count. Evidence: `deleting_several_swatches_uses_each_count_form` in `crates/ui-egui/src/panels/swatches/tests.rs`. @@ -1540,6 +1556,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the XDG desktop portal's [PickColor](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html) on Linux, and the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. On Linux, a session without a portal that has PickColor, a portal that does not return from the call within 25 seconds (VectorCraft's own limit) or leaves the bus before it answers, and a portal that ends the request another way each show a status message and end the wait. Windows, the BSDs and other browsers have no sampler yet; the button is gray there. - **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/it/stroke_visibility.rs`). - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles, both ends of a vector drawn on the art and the start of a clicked one snap as other drawn points do (path segments, construction guides and View › Snap to Glyph's guides included) and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. + A gradient stop that loses its link to a spot swatch (the swatch deleted, a recolor or adjustment that unlinks it, a library gradient, or a library character style linked to a swatch the document lacks) keeps the color it shows at full tint and drops the process plates it printed with that ink, as a stop given a plain color does; this is VectorCraft's own rule. Evidence: `tests::mapping_links_drops_the_process_plates_of_unlinked_stops` in `crates/color`, `tests::linked_stops::deleting_a_spot_swatch_takes_the_process_plates_of_the_stops_it_unlinks` and `tests::library::library_stops_unlinked_from_their_swatch_keep_no_process_plates` in `crates/engine`. - The Gradient panel's Delete Stop sits at the right end of the ramp, level with it, so a panel too short to show the stop fields still shows it; it deletes the selected stop (never below two), as dragging a stop down off the ramp does (#5403). - Conic gradients, VectorCraft's own gradient type: the stops sweep once around the center from the vector, clockwise on the page (a negative aspect, or a reflection, turns the sweep the other way), with a Conic button in the Gradient panel's Type row and the Gradient tool's extent ellipse and aspect handle; they follow transforms, Expand into wedges or a mesh, open from `.af` files, and export to PDF as a function-based shading that PDF import reads back as a conic gradient; SVG, EPS and legacy `.ai` files get solid wedges, and CSS a `conic-gradient`. - Diamond gradients, VectorCraft's own gradient type: the stops run from the center out to a square with a corner at the end of the vector (the aspect stretches it into a rhombus), with a Diamond button in the Gradient panel's Type row and the Gradient tool's square and aspect handle; they follow transforms, Expand into nested squares or a mesh, and export to PDF, SVG, EPS and legacy `.ai` as four linear gradients, one for each quarter of the square (an image in PDF and SVG for translucent stops, a dithered fill, the Perceptual method other than spot color shadings in PDF, or a square with no area), and on type characters in SVG as a radial gradient; PDF import reads a function-based shading whose color is the same along each square about its origin as a diamond gradient. @@ -1646,6 +1663,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur PDF export measures each member of a plain group or layer once to leave out what is off the page: the group or layer is measured through its members (`Node::visual_of_members`), and each member uses that box when it is written. The elements of a knockout group are measured twice, once for the group's box and once to choose the elements on the page (`pdf/src/tests/measuring.rs` counts the measures). A local byte comparison with the previous build found identical output on one open path of 200,000 anchors, 20,000 rectangles in 200 groups of 100, 5,000 point type objects and a document of plain, see-through, clipping and knockout groups. The walk that writes link annotations measures only an object with a URL that opens and a clipping group (`links.rs`), and its output on a document of linked objects in plain and clipping groups is the same byte for byte as when it measured every object. Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. The stops of an axial or radial shading in a DeviceN space that mixes one spot ink with process plates (a spot ink over a constant cyan, for example) link to the ink's swatch at its tint and keep the process plates, and PDF and EPS (Level 3) export write those stops in a DeviceN space of the ink and those plates, so the spot plate is not lost (#3235, `import_color::a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks`). Solid fills that mix a spot ink with process plates, and stops that mix several spot inks, still import as RGB, with the spot inks kept as swatches. + Print separations and Separations Preview print such a stop's process plates on the Cyan, Magenta, Yellow and Black plates beside its tint on the spot plate; with spots printed as process, each process plate prints the stop's plate layered with the spot tint's process equivalent. A freeform gradient's points separate into plates as its stops do, and converting the document's color mode converts them (`print::a_spot_gradient_stop_prints_its_process_plates`, `print::a_freeform_gradients_points_separate_into_plates`, `colorize::separations_preview_shows_a_spot_gradient_stops_process_plates`, `colormgmt::rgb_cmyk_converts_each_freeform_point`). A `.ai` or EPS opened from its editing data keeps both swatches when a pattern's name, or a named color's, is also another swatch's: a pattern's swatch named as a default swatch, a named color or a palette color becomes "Name 2", and a named color named as a gradient or pattern swatch of the palette becomes "Name 2", with the fills, strokes and gradient stops linked to it following it (`ai::tests::a_pattern_swatch_named_as_another_swatch_is_numbered`, `ai::tests::a_color_named_as_a_palette_gradient_or_pattern_is_numbered`). PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning. A CMYK document, or an export that converts colors to a CMYK destination profile, writes RGB and Lab stops in CMYK first (unless the export converts colors to an RGB destination profile), so there such a gradient, Classic or Perceptual, is written as a DeviceN shading that keeps the spot ink. A Perceptual gradient of spot inks is written as a Separation or DeviceN shading of the stops on its curve; a dithered gradient fill, and a Perceptual gradient with RGB or Lab stops other than white or more than 4,096 stops on its curve, are written as images in process colors, with a warning. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`). PDF and EPS export name a spot ink in a gradient's Separation or DeviceN space, and EPS in `%%DocumentCustomColors`, only when a stop linked to it at a tint above 0, or with process plates, colors part of the gradient (VectorCraft's own rule): a stop out of order whose neighboring stretches have no width, and in PDF a stop whose stretches are fully transparent, adds no ink and no empty plate. EPS writes every stop opaque, so there a transparent spot stop keeps its ink (`crates/pdf/src/tests/output.rs`, `crates/eps/src/tests.rs`). @@ -1799,13 +1817,16 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Revert also drops what was started for the content it discards: a file being read for Place, for the place cursor or dropped on the canvas, a bitmap being read for Paste, and a paste waiting at its Swatch Conflict dialog are not placed or pasted in the reverted document, and the status bar says the document was reverted. Edits made while a file is read, a Revert that fails or that a batch rolls back, and a Revert of another document leave the read to finish as before (`crates/ui-egui/src/placing/tests.rs`). - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF. + - PDF export writes a CMYK image that keeps its numbers in an `ICCBased` color space holding, byte for byte, the CMYK profile the image carries (a CMYK TIFF's tag 34675, or a CMYK JPEG's `ICC_PROFILE` APP2 segments) when `output.profiles` is `all` or `taggedSource`, and in PDF/A-2b, PDF/X-3 and PDF/X-4 unless it is `destination` ([the help page](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/pdf-marks-bleeds-and-color-output-options.html)). This covers a CMYK JPEG embedded as it is, one the Compression settings resample or compress again, a TIFF written again, and an image written with its mask as its soft mask. With `none`, and in PDF/X-1a, the image is DeviceCMYK and a JPEG's own profile is left out, so PDF/X-1a export writes a CMYK JPEG that carries a profile. Pixels the Output settings convert are never tagged with the profile of their old numbers. A profile tags an image only when its size, signature, class, CMYK color space and tag table are well formed and it is at most 16 MiB (VectorCraft's own limit). Transparency in a tagged CMYK document blends in the document's CMYK profile; when the file holds no space of that profile it blends in DeviceCMYK, or keeps its RGB space in PDF/A, and never in an image's own profile (`crates/pdf/src/tests/cmykimages.rs`, `export::writer::tests`). - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). - SVG import shows a raster `<image>` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `<image>` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (<https://www.w3.org/TR/css-images-3/#the-image-orientation>). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). - SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (<https://www.w3.org/TR/SVG2/conform.html>). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`). + - SVG import reads the files that a linked SVG file's own `<image>` elements link to, relative paths from that file's folder (<https://www.rfc-editor.org/rfc/rfc3986#section-5.1.3>), as it reads the imported SVG's links: a raster stays linked, an SVG file imports as vector art, and one that can't be read gets the placeholder, its link and a warning. SVG 2 tells a viewer not to load these links (<https://www.w3.org/TR/SVG2/conform.html#secure-static-mode>); reading them is VectorCraft's own rule. The same href in SVG files of two folders names two files. A link that leads back to an SVG file showing it gets the placeholder and a warning, and reading the links of linked SVG files stops at the nesting depth cap and at 1,024 reads and 512 MB per import, the same limits decoded nested SVG images have, counted separately from them. The text of every linked SVG file read counts, and so does every file read for the links of a linked SVG file, raster or SVG; rasters the imported SVG links to don't. Once either limit is passed, no file linked from a linked SVG file is read and its image is left out with a warning, and an SVG file the imported SVG links to is still shown, without the files its own links name, with a warning. Another href to an SVG file already read, spelled differently or in another SVG file, does not read it again, unless a link back or the depth cap changes what it shows there (`crates/svg/tests/it/import_details.rs`, `crates/svg/src/import/files.rs`). - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place <name>: the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - In the Linux Flatpak on GNOME, where the clipboard goes through the compositor's core Wayland protocol, one Copy offers the PNG (`image/png`), the SVG (`image/svg+xml`, with Include SVG Code on) and the text, and Paste takes another app's SVG, text or PNG in that order, a picture over blank text or text that only names it; the Paste menu item is enabled by the types offered (`clipboard::wayland` tests in `apps/vectorcraft/src/clipboard.rs`; not yet run in the Flatpak on a GNOME session). - On that core Wayland clipboard, a paste from an app that leaves its pipe open and sends no data for 2 s fails and pastes nothing, and a paste of VectorCraft's Copy by an app that takes no data for 2 s is stopped and its pipe closed, so later pastes and copies are read and sent again; the 2 s limit is VectorCraft's own and starts again after each byte that moves (`clipboard::wayland::tests::a_read_from_an_app_that_stops_sending_fails_and_closes_the_pipe` and `a_send_to_an_app_that_stops_reading_fails_and_frees_its_thread`). + - On that core Wayland clipboard, files copied in a file manager paste as the first one that is art, before the clipboard's other types, as on the other platforms: Paste reads the `text/uri-list` the file manager offers (RFC 2483), else GNOME Files' `x-special/gnome-copied-files`, off the UI thread, and takes the `file:` URIs with no host or `localhost` (RFC 8089), percent escapes decoded; other URIs are skipped, and a list of more than 1,024 URIs, VectorCraft's own limit, names no file. The Paste menu item is enabled when a file list is offered (`clipboard::wayland::tests::files_copied_in_a_file_manager_paste_as_their_art`; not yet run in the Flatpak on a GNOME session). - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. - On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before. - With Enable Background Export on, an Export for Screens with no folder of its own also writes in the background into the folder its `exportPreset` keeps, with the same progress dialog and Cancel, and the status bar names that folder. When that folder is outside an MCP client's read roots, the reply, its errors and the status bar name it by its own name, as for an export made at once (`dialogs::tests_screens::an_export_into_a_presets_folder_writes_in_the_background`, `fileio::tests_screens::a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out`). Behavior reference: the help page, https://helpx.adobe.com/illustrator/using/exporting-artwork.html (exports run in the background by default). @@ -1817,7 +1838,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed. - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one. - When a document opens with notes and with linked files that changed on disk (Update Links: Manually) or, on the web, can't be read, the status bar shows the line about the linked files, then counts the notes and names the first, and hovering it lists the notes. The same holds when the line about modified files follows the Linked File Not Found dialog, and File › New from Template… shows the notes of the file it opens as File › Open does (`an_open_with_modified_links_keeps_link_notice_and_import_notes_reachable`, `new_from_template_preserves_open_warnings`, `an_open_with_unreadable_links_keeps_import_notes_reachable`, `modified_links_after_the_missing_ones_keep_the_open_notes`, `combined_open_and_link_status_lists_notes_on_hover_and_for_accessibility`). + - On the web, when a document opens with linked files that can't be read and linked files that changed on disk (Update Links: Manually), the status bar shows the line about the unreadable files, and the line about the changed files is the first of the notes its hover lists, ahead of the open's notes. With Ask When Modified the update question opens and the line about the unreadable files stays (`a_web_open_with_unreadable_and_modified_links_keeps_both_lines`). - Linked images and placed documents kept only in symbols, graph designs, patterns or opacity masks are listed in the Links panel after the layers' own, one row per stored object, with their status in its Missing and Modified filters, in Check Links, in the missing and modified files reported when a document opens, in the link watch and in Find in Folder; Relink, Update, Embed, Placement Options and Link Info act on them, and Go To says they have no place on the canvas. Opening a document gives such an object whose id is 0 or repeats another object's a new id. + - Linked images and placed documents in the art of the stored brushes (a scatter or art brush's art, a pattern brush's tiles) get Links panel rows of their own the same way, one per linked or placed object in each brush tile, naming the brush; Relink, Update, Embed, Placement Options, Link Info and Go To act on that art and write it back into the brush library, and the ids they are listed by are kept there. Evidence: `links_in_stored_brush_art_are_listed_and_checked_with_ids`, `links_commands_act_on_images_in_stored_brush_art_by_id`, `links_in_stored_brush_art_open_by_id_and_unreadable_entries_stay`. - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). - A look at the linked files whose worker thread ends without a result, such as after a panic, no longer stops later looks: the app clears it, stops asking for a frame every 100 ms, and the next look starts on schedule (`crates/engine/src/tests/linkwatch.rs`, `crates/ui-egui/src/tests/place.rs`). - Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). @@ -1990,6 +2013,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **A dialog's OK runs what it was opened for:** `ui.dialog.set`, and MCP's `answer_dialog` through it, can set a `__` field only when it is a control of that kind of dialog (a section or tab shown, a link between two fields, a name or search being typed). Every other `__` field, such as the `__command` and `__params` of a confirmation or the generic parameter dialog, a plug-in command dialog's `__command` and `__plugin`, or the paste a swatch conflict waits to finish, is refused with "the `<kind>` dialog cannot set protected field `<field>`"; the request sets no field, and with `action: "confirm"` OK does not run. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/control/tests.rs`. Scores and hours unchanged. - **Only an approval records a plug-in grant (G17):** `vectorcraft_plugins::registry::grant` takes a `GrantToken`, which the engine makes after an approval and when it loads `grants.json`, and `registry::install`, `install_bytes` and folder loads refuse a module that lists permissions no recorded grant gives this very module (`Error::NeedsApproval`), leaving the plug-in under its id and the revision as they were. A module without `permissions`, or with an empty list, installs as before. `docs/plugins.md` says what the approval does not protect against (native code linked into the app, another program running as the same user, the unsigned `grants.json`). Regressions: `crates/plugins/src/registry.rs`, `crates/plugins/tests/grant_sites.rs`, `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **A refused plug-in install writes nothing (G17):** `plugin.install` checks the plug-in installed under the module's id and its grant again while it records the grant and installs the module (`registry::install_checked`), and only then writes the module and its grant to the `Plug-ins` folder, attaches its settings and cache and turns a disabled plug-in on. When another session installed a plug-in with the id that would need `replace: true`, or changed the grant the checks relied on, the install is refused and the folder, `grants.json`, the disabled list and the installed plug-in stay as they were. Without a plug-in store, the approval comes from the grant the install checks again. A `plugin.remove`, or a load of the folder or of a plug-ins folder, in another session waits until the install has written the folder. VectorCraft's own rule. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. +- **Windows and processes take turns on the Plug-ins folder:** each one locks `disabled.lock` in the `Plug-ins` folder while it reads and changes the stored modules, `grants.json`, `disabled.json` and the plug-ins' settings and caches, so two installs of one id keep each module with its own grant, and grants or settings written by two processes are all kept. When the file can't be locked, `plugin.install` and `plugin.remove` fail and change nothing. VectorCraft's own rule. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **An install automation asked for needs the person to start it (G17):** an approval question that a control request, MCP, injected input or a file dialog one of them opened leads to keeps OK disabled and refuses every confirm, a person's click included; it says to choose Object › Plug-ins › Install Plug-in…, and `ui.installPlugin` answers `approval: "personMustStartInstall"`. The person's own Install Plug-in…, File › Open or drop inspects the module again and asks a new question. Automation's `replace: true` no longer skips the question about taking another module's place, and that question now opens in the approval dialog for everyone. VectorCraft's own rule. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. @@ -2095,6 +2119,11 @@ Line / Paragraph Breaks, Set Alignment, Keep Position, Reset Text Transform and refusals), `addons::cleanup::tests` in `vectorcraft-ui-egui` (the submenu and the panel's Text row run them) and `cleanup_text_tools_with_junk_editing_ranges` in the command sweep. +With `ids`, the text tools act on the type the ids name even when it, or its group or layer, is locked or hidden, as +`text.convertListToText` does; from the selection they still take only type that can be edited (VectorCraft's own +rule). Tests: `named_locked_and_hidden_type`, `remove_breaks_named_locked_and_hidden_type` and +`remove_breaks_named_story_with_a_locked_frame` in `cmd::addons::cleanup::text::tests`. + ## Path Precision Tools add-on (2026-10-30) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools, diff --git a/docs/three-d.md b/docs/three-d.md index 6de07fc2fe..92d425ec9d 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -85,8 +85,10 @@ object is where the art was. | Bevel Inside | The bevel carved into the shape (the front face shrinks) instead of added around it (the walls stand outside the art). | | Bevel both sides | The back is bevelled too. | -Holes stay holes, with walls of their own. Bevels move each corner along its bisector (mitres are limited to four -times the width), so a bevel wider than a narrow part of the shape folds over itself. Each facet has a seam ([Seams](#seams)). +Holes stay holes, with walls of their own. Bevels move each corner along its bisector. A corner the bevel opens (a +sharp point with the bevel outside, a narrow notch with it inside) whose miter would reach past four times the width is +cut square across instead; at the other corners the miter is limited to four times the width. A bevel wider than a +narrow part of the shape folds over itself. Each facet has a seam ([Seams](#seams)). ## Inflate diff --git a/docs/type-parity.md b/docs/type-parity.md index 31a835772d..72b8bcfc9d 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -39,7 +39,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | | Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` | | Fallback glyphs keep the text's weight and slant | done | #2491: a character from a fallback face is set in that family's face nearest the run's weight and slant; Bold Italic of a missing family is drawn Bold (Source Sans 3 has no Bold Italic) rather than Italic; a letter and its combining marks (a grapheme cluster) are drawn in one face, the first that has all of them, so marks the text's font lacks attach to their letter instead of drawing as missing glyphs (#4491). Vietnamese letters with two marks (Ể, Ứ, Ự) sit on their letter whether typed precomposed, decomposed or as a vowel plus a tone mark, built from GPOS mark-to-mark in fonts without the precomposed letters. Evidence: `tests::vietnamese` | -| Colour fonts (bitmap, COLR, OpenType SVG) | partial | bitmap glyphs (`sbix`, `CBDT`) done: the largest strike's PNG, transformed with the type, fill and stroke ignored; PDF Type 3 glyphs or images, SVG images with outlined text (live text leaves emoji to the viewer, their fonts not embedded), EPS and legacy `.ai` images, Create Outlines images; not `sbix` duplicate, flipped, JPEG or TIFF glyphs, or `CBDT` BGRA or mask data, or ruby; in envelopes they warp as images do, and a Warp effect moves them without bending them. COLR (v0 and v1) done: a glyph's layers as vector art (solid colours, linear and radial gradients that pad, repeat or reflect (the [COLR specification](https://learn.microsoft.com/en-us/typography/opentype/spec/colr)'s extend modes), clip glyphs and boxes, blend modes, transforms, palette 0, the foreground colour from the run's fill, variable fonts at the face's instance), transformed with the type; drawn on the canvas, written as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as text in PDF (Type 3 glyphs), and Create Outlines makes groups of editable paths. Paints documents have no form for draw the glyph as an image (256 px per em): sweep gradients, Porter-Duff compositing, radial gradients from a circle that isn't the centre, radial gradients that repeat or reflect and start at a circle, gradients that repeat or reflect with a stop whose color isn't in the palette. Not ruby. COLR and SVG glyphs also go with type that geometry effects reshape (on the canvas and in exports), into Expand Appearance and EMF/WMF export, as their art, and bitmap glyphs as images. OpenType SVG done: a glyph's SVG document (its element `glyph<gid>`) is read by the SVG importer into vector art in font units, `currentColor` in the run's fill (and the legacy `context-fill` and `context-stroke` in its fill and stroke), a `viewBox` mapped onto the em square, and then drawn, exported and outlined as COLR glyphs are; in PDF real text such glyphs are text that draws nothing, with their art drawn where they sit. Images in glyph documents are in the art, holding their pixels until a document takes them in; text, scripts and embedded content are left out, as the table rules them out. Not CPAL palette variables. Evidence: `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::svg_glyph_images_draw_and_outline`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`, `tests_colourfonts::expand_appearance_keeps_the_colour_glyphs_of_type_with_effects`, `render::tests_colrglyphs::colour_glyphs_go_with_type_that_a_geometry_effect_reshapes`, `tests_bitmaps`, `tests_colr`, `render::tests_bitmapglyphs`, `render::tests_colrglyphs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/it/colour_glyphs.rs`, `eps` `colour_bitmap_glyphs_*` and `colr_glyphs_*`, `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups` | +| Colour fonts (bitmap, COLR, OpenType SVG) | partial | bitmap glyphs (`sbix`, `CBDT`) done: the largest strike's PNG, transformed with the type, fill and stroke ignored; PDF Type 3 glyphs or images, SVG images with outlined text (live text leaves emoji to the viewer, their fonts not embedded), EPS and legacy `.ai` images, Create Outlines images; not `sbix` duplicate, flipped, JPEG or TIFF glyphs, or `CBDT` BGRA or mask data, or ruby; in envelopes they warp as images do, and a Warp effect moves them without bending them. COLR (v0 and v1) done: a glyph's layers as vector art (solid colours, linear and radial gradients that pad, repeat or reflect (the [COLR specification](https://learn.microsoft.com/en-us/typography/opentype/spec/colr)'s extend modes), clip glyphs and boxes, blend modes, transforms, palette 0, the foreground colour from the run's fill, variable fonts at the face's instance), transformed with the type; drawn on the canvas, written as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as text in PDF (Type 3 glyphs), and Create Outlines makes groups of editable paths. Paints documents have no form for draw the glyph as an image (256 px per em): sweep gradients, Porter-Duff compositing, radial gradients from a circle that isn't the centre, radial gradients that repeat or reflect and start at a circle, gradients that repeat or reflect with a stop whose color isn't in the palette. Not ruby. COLR and SVG glyphs also go with type that geometry effects reshape (on the canvas and in exports), into Expand Appearance and EMF/WMF export, as their art, and bitmap glyphs as images. OpenType SVG done: a glyph's SVG document (its element `glyph<gid>`) is read by the SVG importer into vector art in font units, `currentColor` in the run's fill (and the legacy `context-fill` and `context-stroke` in its fill and stroke), a `viewBox` mapped onto the em square, and then drawn, exported and outlined as COLR glyphs are; in PDF real text such glyphs are text that draws nothing, with their art drawn where they sit. Images in glyph documents are in the art, holding their pixels until a document takes them in; text, scripts and embedded content are left out, as the table rules them out. Not CPAL palette variables. Evidence: `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::svg_glyph_images_draw_and_outline`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`, `tests_colourfonts::expand_appearance_keeps_the_colour_glyphs_of_type_with_effects`, `render::tests_colrglyphs::colour_glyphs_go_with_type_that_a_geometry_effect_reshapes`, `tests_bitmaps`, `tests_colr`, `render::tests_bitmapglyphs`, `render::tests_colrglyphs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/it/colour_glyphs.rs`, `eps` `colour_bitmap_glyphs_*`, `colr_glyphs_*` and `type_taken_back_for_the_page_limit_takes_its_glyph_images_with_it`, `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups` | | Every-line and Single-line composers | done | tuned by eye, never compared side by side | | Optical Margin Alignment | done | Type › Optical Margin Alignment, `type.opticalMarginAlignment`: per type object, with one setting for all the frames of a thread, in point and area type of left-to-right paragraphs, and at the brackets of type on a path whose first paragraph runs left to right; right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, do not hang | | System Layout | done | Character panel menu › System Layout, `type.systemLayout`: the whole document, off by default and for documents from before it, saved only when on. On, every text object advances by whole points (at least one when a character takes space). New point, area and path type, threaded frames, placed text, graph and dimension labels and pasted plain text inherit it. Evidence: `tests::system_layout_rounds_positive_advances_to_whole_points`, `typecmd::area_tests::system_layout_sets_the_document_and_every_text_object`, `panels::character::tests::system_layout_menu_item_toggles_the_document` | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9bcce16a0b..dc68706f26 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -384,7 +384,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. | | Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) | -| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it; View more opens the tutorial's practice sample as a new untitled document, Finish tutorial then marks it finished, and Next Up shows the next tutorial in the same skill (no video) | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.skills`, `learn.show`, `learn.locateTool`, `learn.openSample`, `learn.finish`, `learn.next`; the mark's look and 3-second lifetime are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | +| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with progress rings that fill and counts of tutorials finished, a tutorial shows its numbered steps with a check box each that marks the step done once its sample is open, a finished tutorial has a check mark, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it; View more opens the tutorial's practice sample as a new untitled document, Finish tutorial then marks it finished with all its steps, and Next Up shows the next tutorial in the same skill; Reset progress clears the progress (no video) | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.skills`, `learn.show`, `learn.locateTool`, `learn.openSample`, `learn.markStep`, `learn.finish`, `learn.next`, `learn.resetProgress`; the mark's look and 3-second lifetime, marking steps by hand, keeping the progress in the preferences and Reset progress are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | | Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) | | Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet | | Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests |